gitlab_quality-test_tooling 2.16.1 → 2.18.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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/exe/relate-failure-issue +5 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/group_issues/error_message_normalizer.rb +49 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/error_pattern_matcher.rb +36 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/failure_processor.rb +73 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/group_results_in_issues.rb +48 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/incident_checker.rb +61 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/issue_base.rb +48 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/issue_creator.rb +44 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/issue_finder.rb +79 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/issue_formatter.rb +83 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/issue_manager.rb +33 -0
- data/lib/gitlab_quality/test_tooling/report/group_issues/issue_updater.rb +87 -0
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +128 -5
- data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +38 -8
- data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +4 -0
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- data/lib/gitlab_quality/test_tooling.rb +2 -0
- metadata +13 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7db77718644f72fd0a096182fdf76ad10c912002f0770e245ac782a9b238b690
|
4
|
+
data.tar.gz: 7b67154f177663fdc2bd19882b128e5eaa3540f20c31c50fddcf73c6661590ff
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0776f0aef2a4076a7a0bfd71c902d57400eea5388982e62583d689038137ed7fe1166368dabfb006999069e32525fd47d752faf92afffa536b9cbe72303c207f
|
7
|
+
data.tar.gz: c3145f170b88abebe7e2a6875d94ccca55fccb370972f37c7679479257186a5e2d73eaedb6bb799139c72bfc32ade74f60e1e5cf07aebb5aa7e3bb8621c7a65d
|
data/Gemfile.lock
CHANGED
data/exe/relate-failure-issue
CHANGED
@@ -58,6 +58,10 @@ options = OptionParser.new do |opts|
|
|
58
58
|
params[:dry_run] = true
|
59
59
|
end
|
60
60
|
|
61
|
+
opts.on("--group-similar", "Enable grouping similar issues") do
|
62
|
+
params[:group_similar] = true
|
63
|
+
end
|
64
|
+
|
61
65
|
opts.on_tail('-v', '--version', 'Show the version') do
|
62
66
|
require_relative "../lib/gitlab_quality/test_tooling/version"
|
63
67
|
puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
|
@@ -66,6 +70,7 @@ options = OptionParser.new do |opts|
|
|
66
70
|
|
67
71
|
opts.on_tail('-h', '--help', 'Show the usage') do
|
68
72
|
puts "Purpose: Relate test failures to failure issues from RSpec report files (JSON or JUnit XML)"
|
73
|
+
puts ""
|
69
74
|
puts opts
|
70
75
|
exit
|
71
76
|
end
|
@@ -4,7 +4,7 @@ module GitlabQuality
|
|
4
4
|
module TestTooling
|
5
5
|
module GitlabClient
|
6
6
|
class IssuesDryClient < IssuesClient
|
7
|
-
def create_issue(title:, description:, labels:, issue_type: 'issue',
|
7
|
+
def create_issue(title:, description:, labels:, issue_type: 'issue', confidential: false)
|
8
8
|
attrs = { description: description, labels: labels, confidential: confidential }
|
9
9
|
|
10
10
|
puts "The following #{issue_type} would have been created:"
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'digest'
|
4
|
+
require 'openssl'
|
5
|
+
|
6
|
+
module GitlabQuality
|
7
|
+
module TestTooling
|
8
|
+
module Report
|
9
|
+
module GroupIssues
|
10
|
+
class ErrorMessageNormalizer
|
11
|
+
NORMALIZATION_PATTERNS = [
|
12
|
+
{ pattern: /\d{4}-\d{2}-\d{2}T?[ ]?\d{2}:\d{2}:\d{2}(\.\d+)?Z?/, replacement: "<TIMESTAMP>" },
|
13
|
+
{ pattern: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, replacement: "<UUID>" },
|
14
|
+
{ pattern: /Correlation Id: [\w]+/, replacement: "Correlation Id: <UUID>" },
|
15
|
+
{ pattern: /Fabrication of QA::Resource::[A-Za-z:]+/, replacement: "Fabrication of QA::Resource::<RESOURCE>" },
|
16
|
+
{ pattern: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b/, replacement: "<IP>" },
|
17
|
+
{ pattern: /user\d+/, replacement: "<USER>" },
|
18
|
+
{ pattern: /group\d+/, replacement: "<GROUP>" },
|
19
|
+
{ pattern: /project\d+/, replacement: "<PROJECT>" },
|
20
|
+
{ pattern: %r{https?://[^/\s]+/[^\s]*}, replacement: "<URL>" },
|
21
|
+
{ pattern: %r{/tmp/[^\s]+}, replacement: "<TMPFILE>" },
|
22
|
+
{ pattern: %r{/var/[^\s]+}, replacement: "<VARFILE>" },
|
23
|
+
{ pattern: /token=[^\s&]+/, replacement: "token=<TOKEN>" },
|
24
|
+
{ pattern: /after \d+ seconds/, replacement: "after <N> seconds" },
|
25
|
+
{ pattern: /waited \d+ seconds/, replacement: "waited <N> seconds" },
|
26
|
+
{ pattern: /\d+ attempts?/, replacement: "<N> attempts" },
|
27
|
+
{ pattern: /\s+/, replacement: " " }
|
28
|
+
].freeze
|
29
|
+
|
30
|
+
def normalize(message)
|
31
|
+
return "" if message.nil? || message.empty?
|
32
|
+
|
33
|
+
result = message.dup.strip
|
34
|
+
|
35
|
+
NORMALIZATION_PATTERNS.each do |pattern_rule|
|
36
|
+
result.gsub!(pattern_rule[:pattern], pattern_rule[:replacement])
|
37
|
+
end
|
38
|
+
|
39
|
+
result.strip
|
40
|
+
end
|
41
|
+
|
42
|
+
def create_fingerprint(normalized_message)
|
43
|
+
OpenSSL::Digest::SHA256.hexdigest(normalized_message.downcase)[0..15]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class ErrorPatternMatcher
|
8
|
+
ENVIRONMENT_ERROR_PATTERNS = [
|
9
|
+
{ name: "http_500_api_fabrication", pattern: /Fabrication of .+ using the API failed \(500\)/i },
|
10
|
+
{ name: "http_500_internal_server", pattern: /(500 Internal Server Error|request returned \(500\)|Expected \(200\), request returned \(500\))/i },
|
11
|
+
{ name: "http_400_backend_failing", pattern: /failed \(400\) with.+connections to all backends failing/i },
|
12
|
+
{ name: "http_503_service_unavailable", pattern: /Unexpected status code 503/i },
|
13
|
+
{ name: "pipeline_creation_timeout", pattern: /Wait for pipeline to be created failed after \d+ seconds/i },
|
14
|
+
{ name: "event_timeout", pattern: /(Timed out waiting for event|EventNotFoundError: Timed out waiting)/i },
|
15
|
+
{ name: "git_rpc_failure", pattern: /error: RPC failed; HTTP 500/i },
|
16
|
+
{ name: "repository_fabricate_error", pattern: /Repository fabricate/i }
|
17
|
+
].freeze
|
18
|
+
|
19
|
+
def match(error_message)
|
20
|
+
return nil if error_message.nil? || error_message.empty?
|
21
|
+
|
22
|
+
ENVIRONMENT_ERROR_PATTERNS.find { |pattern_def| error_message.match?(pattern_def[:pattern]) }
|
23
|
+
end
|
24
|
+
|
25
|
+
def environment_error?(error_message)
|
26
|
+
!match(error_message).nil?
|
27
|
+
end
|
28
|
+
|
29
|
+
def pattern_name(error_message)
|
30
|
+
match(error_message)&.dig(:name)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class FailureProcessor
|
8
|
+
DEFAULT_MIN_FAILURES = 2
|
9
|
+
|
10
|
+
def initialize(options = {})
|
11
|
+
@options = options
|
12
|
+
@pattern_matcher = options[:pattern_matcher] || ErrorPatternMatcher.new
|
13
|
+
@normalizer = options[:normalizer] || ErrorMessageNormalizer.new
|
14
|
+
@config = options[:config] || {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def process_failures(failures, &)
|
18
|
+
Runtime::Logger.info "Processing #{failures.size} failures for grouping..."
|
19
|
+
grouped_failures = {}
|
20
|
+
|
21
|
+
failures.each do |failure|
|
22
|
+
process_single_failure(failure, grouped_failures)
|
23
|
+
end
|
24
|
+
|
25
|
+
Runtime::Logger.info "Found #{grouped_failures.size} groups before filtering"
|
26
|
+
grouped_failures.each_value(&)
|
27
|
+
end
|
28
|
+
|
29
|
+
def filter_groups_by_threshold(grouped_failures)
|
30
|
+
min_failures = @config.dig(:thresholds, :min_failures_to_group) || DEFAULT_MIN_FAILURES
|
31
|
+
|
32
|
+
grouped_failures.select! do |_fingerprint, grouped_failure|
|
33
|
+
grouped_failure[:failures].size >= min_failures
|
34
|
+
end
|
35
|
+
|
36
|
+
Runtime::Logger.info "Found #{grouped_failures.size} groups after filtering"
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def process_single_failure(failure, grouped_failures)
|
42
|
+
error_message = failure.dig(:exception, 'message') || failure.dig(:exceptions, 0, 'message')
|
43
|
+
Runtime::Logger.info "Processing failure: #{failure[:description]}"
|
44
|
+
Runtime::Logger.info "Error message: #{error_message[0..100]}..." if error_message
|
45
|
+
|
46
|
+
return unless error_message && @pattern_matcher.environment_error?(error_message)
|
47
|
+
|
48
|
+
Runtime::Logger.info "Identified as environment error"
|
49
|
+
group_environment_failure(failure, error_message, grouped_failures)
|
50
|
+
end
|
51
|
+
|
52
|
+
def group_environment_failure(failure, error_message, grouped_failures)
|
53
|
+
normalized_message = @normalizer.normalize(error_message)
|
54
|
+
fingerprint = @normalizer.create_fingerprint(normalized_message)
|
55
|
+
pattern_name = @pattern_matcher.pattern_name(error_message)
|
56
|
+
|
57
|
+
grouped_failures[fingerprint] ||= build_grouped_failure(fingerprint, pattern_name, normalized_message)
|
58
|
+
grouped_failures[fingerprint][:failures] << failure
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_grouped_failure(fingerprint, pattern_name, normalized_message)
|
62
|
+
{
|
63
|
+
fingerprint: fingerprint,
|
64
|
+
pattern_name: pattern_name,
|
65
|
+
normalized_message: normalized_message,
|
66
|
+
failures: []
|
67
|
+
}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gitlab'
|
4
|
+
require 'time'
|
5
|
+
require_relative 'error_pattern_matcher'
|
6
|
+
require_relative 'error_message_normalizer'
|
7
|
+
require_relative 'incident_checker'
|
8
|
+
|
9
|
+
module GitlabQuality
|
10
|
+
module TestTooling
|
11
|
+
module Report
|
12
|
+
module GroupIssues
|
13
|
+
class GroupResultsInIssues
|
14
|
+
attr_reader :grouped_failures
|
15
|
+
|
16
|
+
def initialize(options = {})
|
17
|
+
@options = options
|
18
|
+
@failure_processor = FailureProcessor.new(options)
|
19
|
+
@issue_manager = IssueManager.new(options)
|
20
|
+
@grouped_failures = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
def process_failures(failures)
|
24
|
+
@failure_processor.process_failures(failures) do |grouped_failure|
|
25
|
+
fingerprint = grouped_failure[:fingerprint]
|
26
|
+
@grouped_failures[fingerprint] = grouped_failure
|
27
|
+
end
|
28
|
+
|
29
|
+
@failure_processor.filter_groups_by_threshold(@grouped_failures)
|
30
|
+
end
|
31
|
+
|
32
|
+
def process_issues
|
33
|
+
@grouped_failures.each_value do |grouped_failure|
|
34
|
+
@issue_manager.create_or_update_issue(grouped_failure)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def summary
|
39
|
+
{
|
40
|
+
grouped_issues: @grouped_failures.size,
|
41
|
+
total_grouped_failures: @grouped_failures.values.sum { |group| group[:failures].size }
|
42
|
+
}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gitlab'
|
4
|
+
|
5
|
+
module GitlabQuality
|
6
|
+
module TestTooling
|
7
|
+
module Report
|
8
|
+
module GroupIssues
|
9
|
+
class IncidentChecker
|
10
|
+
GITLAB_PRODUCTION_PROJECT_ID = '7444821' # gitlab-com/gl-infra/production
|
11
|
+
|
12
|
+
def self.get_active_incidents(token: nil, gitlab_url: 'https://gitlab.com')
|
13
|
+
return [] unless token
|
14
|
+
|
15
|
+
begin
|
16
|
+
client = GitlabClient::IssuesClient.new(token: token, endpoint: "#{gitlab_url}/api/v4", project: GITLAB_PRODUCTION_PROJECT_ID)
|
17
|
+
|
18
|
+
issues = client.find_issues(options: {
|
19
|
+
labels: 'Incident::Active',
|
20
|
+
state: 'opened',
|
21
|
+
per_page: 10,
|
22
|
+
order_by: 'created_at',
|
23
|
+
sort: 'desc'
|
24
|
+
})
|
25
|
+
|
26
|
+
issues.map do |issue|
|
27
|
+
{
|
28
|
+
title: issue.title,
|
29
|
+
url: issue.web_url
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
rescue Gitlab::Error::Error => e
|
34
|
+
Runtime::Logger.error "GitLab API error fetching incidents: #{e.message}"
|
35
|
+
[]
|
36
|
+
rescue StandardError => e
|
37
|
+
Runtime::Logger.error "Warning: Could not fetch active incidents: #{e.message}"
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.format_incidents_for_issue(incidents)
|
43
|
+
return "" if incidents.empty?
|
44
|
+
|
45
|
+
incident_list = incidents.map { |inc| "- [#{inc[:title]}](#{inc[:url]})" }.join("\n")
|
46
|
+
|
47
|
+
<<~MARKDOWN
|
48
|
+
|
49
|
+
### Related GitLab Incidents
|
50
|
+
The following active incidents may be related to these test failures:
|
51
|
+
|
52
|
+
#{incident_list}
|
53
|
+
|
54
|
+
Check the [GitLab Production Issues](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/?label_name%5B%5D=Incident%3A%3AActive) for updates.
|
55
|
+
MARKDOWN
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class IssueBase
|
8
|
+
def initialize(client, options = {})
|
9
|
+
@client = client
|
10
|
+
@options = options
|
11
|
+
@gitlab_url = ENV.fetch('CI_SERVER_URL', 'https://gitlab.com')
|
12
|
+
@project_id = options[:target_project] || ENV['RESULTS_ISSUE_PROJECT'] || ENV.fetch('CI_PROJECT_ID', nil)
|
13
|
+
@token = options[:token]
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def handle_gitlab_api_error(operation, context = nil)
|
19
|
+
yield
|
20
|
+
rescue Gitlab::Error::Error => e
|
21
|
+
log_gitlab_error(operation, context, e)
|
22
|
+
nil
|
23
|
+
rescue StandardError => e
|
24
|
+
log_standard_error(operation, context, e)
|
25
|
+
nil
|
26
|
+
end
|
27
|
+
|
28
|
+
def log_gitlab_error(operation, context, error)
|
29
|
+
context_info = context ? " #{context}" : ""
|
30
|
+
Runtime::Logger.error "GitLab API error #{operation}#{context_info}: #{error.message}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def log_standard_error(operation, context, error)
|
34
|
+
context_info = context ? " #{context}" : ""
|
35
|
+
Runtime::Logger.error "Error #{operation}#{context_info}: #{error.message}"
|
36
|
+
end
|
37
|
+
|
38
|
+
def display_description_preview(description, title = "Description preview:")
|
39
|
+
Runtime::Logger.info title
|
40
|
+
lines = description.split("\n")
|
41
|
+
Runtime::Logger.info lines.first(15).join("\n")
|
42
|
+
Runtime::Logger.info "..." if lines.length > 15
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class IssueCreator < IssueBase
|
8
|
+
GROUPED_ISSUE_LABELS = Set.new(%w[test failure::test-environment automation:bot-authored type::maintenance]).freeze
|
9
|
+
|
10
|
+
def initialize(client, options = {})
|
11
|
+
super
|
12
|
+
@formatter = IssueFormatter.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def create_new_issue(grouped_failure)
|
16
|
+
title = @formatter.generate_issue_title(grouped_failure)
|
17
|
+
description = @formatter.generate_issue_description(grouped_failure, @options)
|
18
|
+
labels = GROUPED_ISSUE_LABELS
|
19
|
+
|
20
|
+
Runtime::Logger.info "Creating new grouped issue: #{title} (#{grouped_failure[:failures].size} failures)"
|
21
|
+
|
22
|
+
create_issue(
|
23
|
+
title: title,
|
24
|
+
description: description,
|
25
|
+
labels: labels,
|
26
|
+
failures: grouped_failure[:failures]
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def create_issue(title:, description:, labels:, failures:)
|
33
|
+
Runtime::Logger.info "Creating issue: #{title} with #{failures.size} failures"
|
34
|
+
|
35
|
+
handle_gitlab_api_error("creating issue") do
|
36
|
+
issue = @client.create_issue(title: title, description: description, labels: labels.join(','))
|
37
|
+
Runtime::Logger.info "Issue created successfully: #{issue.web_url}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class IssueFinder
|
8
|
+
DEFAULT_MAX_AGE_HOURS = 24
|
9
|
+
ISSUES_PER_PAGE = 50
|
10
|
+
|
11
|
+
def initialize(client, options = {})
|
12
|
+
@client = client
|
13
|
+
@options = options
|
14
|
+
@token = options[:token]
|
15
|
+
@project_id = options[:target_project] || ENV['RESULTS_ISSUE_PROJECT'] || ENV.fetch('CI_PROJECT_ID', nil)
|
16
|
+
end
|
17
|
+
|
18
|
+
def find_existing_issue(grouped_failure)
|
19
|
+
find_related_issue_by_fingerprint(
|
20
|
+
fingerprint: grouped_failure[:fingerprint],
|
21
|
+
max_age_hours: DEFAULT_MAX_AGE_HOURS
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def find_related_issue_by_fingerprint(fingerprint:, max_age_hours:)
|
28
|
+
Runtime::Logger.info "Searching for existing issue with fingerprint: #{fingerprint}"
|
29
|
+
|
30
|
+
begin
|
31
|
+
issues = fetch_recent_open_issues(max_age_hours)
|
32
|
+
matching_issue = find_issue_with_fingerprint(issues, fingerprint)
|
33
|
+
if matching_issue
|
34
|
+
Runtime::Logger.info "Found existing issue: ##{matching_issue.iid} - #{matching_issue.title}"
|
35
|
+
convert_to_struct(matching_issue)
|
36
|
+
end
|
37
|
+
rescue Gitlab::Error::Error => e
|
38
|
+
Runtime::Logger.error "GitLab API error searching for issues: #{e.message}"
|
39
|
+
nil
|
40
|
+
rescue StandardError => e
|
41
|
+
Runtime::Logger.error "Error searching for existing issues: #{e.message}"
|
42
|
+
nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def fetch_recent_open_issues(max_age_hours)
|
47
|
+
cutoff_time = Time.now - (max_age_hours * 3600)
|
48
|
+
|
49
|
+
@client.find_issues(options: {
|
50
|
+
state: 'opened',
|
51
|
+
created_after: cutoff_time.utc.iso8601,
|
52
|
+
per_page: ISSUES_PER_PAGE,
|
53
|
+
order_by: 'created_at',
|
54
|
+
sort: 'desc'
|
55
|
+
})
|
56
|
+
end
|
57
|
+
|
58
|
+
def find_issue_with_fingerprint(issues, fingerprint)
|
59
|
+
fingerprint_tag = "grouped-failure-fingerprint:#{fingerprint}"
|
60
|
+
issues.find { |issue| issue.description&.include?(fingerprint_tag) }
|
61
|
+
end
|
62
|
+
|
63
|
+
def convert_to_struct(obj)
|
64
|
+
return obj unless obj.is_a?(Hash)
|
65
|
+
|
66
|
+
struct_class = Struct.new(*obj.keys.map(&:to_sym))
|
67
|
+
struct = struct_class.new
|
68
|
+
|
69
|
+
obj.each do |key, value|
|
70
|
+
struct[key.to_sym] = value.is_a?(Hash) ? convert_to_struct(value) : value
|
71
|
+
end
|
72
|
+
|
73
|
+
struct
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class IssueFormatter
|
8
|
+
def generate_issue_title(grouped_failure)
|
9
|
+
case grouped_failure[:pattern_name]
|
10
|
+
when /http_500/ then "Environment Issue: HTTP 500 Internal Server Errors"
|
11
|
+
when /http_400/ then "Environment Issue: Backend Connection Failures"
|
12
|
+
when /http_503/ then "Environment Issue: Service Unavailable (503)"
|
13
|
+
when /timeout/ then "Environment Issue: Timeout Failures"
|
14
|
+
when /git_rpc|repository/ then "Environment Issue: Repository/Git Operation Failures"
|
15
|
+
else "Environment Issue: Multiple Similar Failures"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate_issue_description(grouped_failure, options = {})
|
20
|
+
active_incidents = IncidentChecker.get_active_incidents(token: options[:token])
|
21
|
+
incident_section = IncidentChecker.format_incidents_for_issue(active_incidents)
|
22
|
+
|
23
|
+
<<~MARKDOWN
|
24
|
+
## Environment Issue: #{grouped_failure[:pattern_name]}
|
25
|
+
|
26
|
+
Multiple tests have failed with similar error patterns, indicating an environment-related issue affecting multiple test cases.
|
27
|
+
|
28
|
+
### Error Pattern
|
29
|
+
```
|
30
|
+
#{grouped_failure[:normalized_message]}
|
31
|
+
```
|
32
|
+
|
33
|
+
### Affected Tests (#{grouped_failure[:failures].size} failures)
|
34
|
+
#{format_affected_tests(grouped_failure[:failures])}
|
35
|
+
|
36
|
+
### Pipeline Information
|
37
|
+
#{format_pipeline_info(grouped_failure[:failures].first)}
|
38
|
+
|
39
|
+
### Recommended Actions
|
40
|
+
#{generate_recommended_actions(grouped_failure)}
|
41
|
+
|
42
|
+
#{incident_section}
|
43
|
+
---
|
44
|
+
<!-- grouped-failure-fingerprint:#{grouped_failure[:fingerprint]} -->
|
45
|
+
MARKDOWN
|
46
|
+
end
|
47
|
+
|
48
|
+
def format_affected_tests(failures)
|
49
|
+
failures.map do |failure|
|
50
|
+
job_name = failure[:ci_job_url] || failure.dig(:ci_job, :name) || 'unknown_job'
|
51
|
+
spec_file = failure[:file_path] || failure[:file] || 'unknown_spec'
|
52
|
+
line_number = failure[:line_number] || failure[:line]
|
53
|
+
test_name = failure[:description] || failure[:test_name] || 'Unknown test'
|
54
|
+
|
55
|
+
spec_with_line = line_number.to_s.empty? ? spec_file : "#{spec_file}:#{line_number}"
|
56
|
+
"- **#{test_name}** (Job: `#{job_name}`, Spec: `#{spec_with_line}`)"
|
57
|
+
end.join("\n")
|
58
|
+
end
|
59
|
+
|
60
|
+
def format_pipeline_info(failure)
|
61
|
+
pipeline_url = ENV['CI_PIPELINE_URL'] || "Pipeline #{ENV.fetch('CI_PIPELINE_ID', 'unknown')}"
|
62
|
+
job_url = failure[:ci_job_url] || 'Unknown job'
|
63
|
+
|
64
|
+
"- **Pipeline**: #{pipeline_url}\n- **Job**: #{job_url}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def generate_recommended_actions(grouped_failure)
|
68
|
+
case grouped_failure[:pattern_name]
|
69
|
+
when /http_500/
|
70
|
+
"1. Check GitLab instance status and logs\n2. Verify database connectivity\n3. Review application server health"
|
71
|
+
when /timeout/
|
72
|
+
"1. Check network connectivity\n2. Review timeout configurations\n3. Monitor system resources"
|
73
|
+
when /git_rpc|repository/
|
74
|
+
"1. Verify Git repository accessibility\n2. Check Gitaly service status\n3. Review storage capacity"
|
75
|
+
else
|
76
|
+
"1. Check if there are ongoing incidents affecting the GitLab instance\n2. Verify API endpoints are responding correctly\n3. Review system logs for related errors"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class IssueManager
|
8
|
+
DEFAULT_MAX_AGE_HOURS = 24
|
9
|
+
ISSUES_PER_PAGE = 50
|
10
|
+
GROUPED_ISSUE_LABELS = Set.new(%w[test failure::test-environment automation:bot-authored type::maintenance]).freeze
|
11
|
+
|
12
|
+
def initialize(options = {})
|
13
|
+
@options = options
|
14
|
+
@client = options[:gitlab]
|
15
|
+
@issue_finder = IssueFinder.new(@client, @options)
|
16
|
+
@issue_updater = IssueUpdater.new(@client, @options)
|
17
|
+
@issue_creator = IssueCreator.new(@client, @options)
|
18
|
+
end
|
19
|
+
|
20
|
+
def create_or_update_issue(grouped_failure)
|
21
|
+
existing_issue = @issue_finder.find_existing_issue(grouped_failure)
|
22
|
+
|
23
|
+
if existing_issue
|
24
|
+
@issue_updater.update_existing_issue(existing_issue, grouped_failure)
|
25
|
+
else
|
26
|
+
@issue_creator.create_new_issue(grouped_failure)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitlabQuality
|
4
|
+
module TestTooling
|
5
|
+
module Report
|
6
|
+
module GroupIssues
|
7
|
+
class IssueUpdater < IssueBase
|
8
|
+
def update_existing_issue(issue, grouped_failure)
|
9
|
+
log_issue_update(issue, grouped_failure)
|
10
|
+
append_failures_to_issue(issue, grouped_failure[:failures])
|
11
|
+
add_update_comment(issue, grouped_failure[:failures].size)
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def append_failures_to_issue(issue, failures)
|
17
|
+
current_issue = @client.find_issues(iid: issue.iid).first
|
18
|
+
return unless current_issue
|
19
|
+
|
20
|
+
existing_description = current_issue.description
|
21
|
+
affected_tests_match = existing_description.match(/### Affected Tests \((\d+) failures?\)/)
|
22
|
+
return unless affected_tests_match
|
23
|
+
|
24
|
+
updated_description = build_updated_description(existing_description, affected_tests_match, failures)
|
25
|
+
update_issue_description(issue, updated_description, failures.size)
|
26
|
+
end
|
27
|
+
|
28
|
+
def update_issue_description(issue, updated_description, failure_count)
|
29
|
+
handle_gitlab_api_error("updating issue", "##{issue.web_url}") do
|
30
|
+
@client.edit_issue(iid: issue.iid, options: { description: updated_description })
|
31
|
+
Runtime::Logger.info "Successfully appended #{failure_count} failures to issue #{issue.web_url}"
|
32
|
+
true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def build_updated_description(existing_description, affected_tests_match, failures)
|
37
|
+
current_count = affected_tests_match[1].to_i
|
38
|
+
new_count = current_count + failures.size
|
39
|
+
|
40
|
+
updated_description = existing_description.gsub(
|
41
|
+
/### Affected Tests \(\d+ failures?\)/,
|
42
|
+
"### Affected Tests (#{new_count} failures)"
|
43
|
+
)
|
44
|
+
insert_new_failures(updated_description, failures)
|
45
|
+
end
|
46
|
+
|
47
|
+
def insert_new_failures(description, failures)
|
48
|
+
formatter = IssueFormatter.new
|
49
|
+
new_failure_entries = formatter.format_affected_tests(failures)
|
50
|
+
|
51
|
+
test_section_end = description.index('### Pipeline Information')
|
52
|
+
return description unless test_section_end
|
53
|
+
|
54
|
+
insertion_point = description.rindex("\n", test_section_end - 1)
|
55
|
+
return description unless insertion_point
|
56
|
+
|
57
|
+
"#{description[0..insertion_point]}#{new_failure_entries}\n#{description[insertion_point + 1..]}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def add_update_comment(issue, failure_count)
|
61
|
+
pipeline_url = ENV['CI_PIPELINE_URL'] || "Pipeline #{ENV.fetch('CI_PIPELINE_ID', nil)}"
|
62
|
+
comment = build_update_comment(pipeline_url, failure_count)
|
63
|
+
add_comment_to_issue(issue, comment)
|
64
|
+
end
|
65
|
+
|
66
|
+
def build_update_comment(pipeline_url, failure_count)
|
67
|
+
"🔄 **New failures added from #{pipeline_url}**\n\n" \
|
68
|
+
"Added #{failure_count} additional test failures with the same error pattern."
|
69
|
+
end
|
70
|
+
|
71
|
+
def add_comment_to_issue(issue, comment)
|
72
|
+
handle_gitlab_api_error("adding comment to issue", issue.web_url) do
|
73
|
+
@client.create_issue_note(iid: issue.iid, note: comment)
|
74
|
+
Runtime::Logger.info "Comment added successfully to issue #{issue.web_url}"
|
75
|
+
true
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def log_issue_update(issue, grouped_failure)
|
80
|
+
pipeline_id = ENV.fetch('CI_PIPELINE_ID', nil)
|
81
|
+
Runtime::Logger.info "Updating existing issue ##{issue.iid} with #{grouped_failure[:failures].size} new failures from pipeline #{pipeline_id}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -3,6 +3,10 @@
|
|
3
3
|
require 'nokogiri'
|
4
4
|
require 'rubygems/text'
|
5
5
|
|
6
|
+
require_relative 'group_issues/error_pattern_matcher'
|
7
|
+
require_relative 'group_issues/error_message_normalizer'
|
8
|
+
require_relative 'group_issues/group_results_in_issues'
|
9
|
+
|
6
10
|
module GitlabQuality
|
7
11
|
module TestTooling
|
8
12
|
module Report
|
@@ -12,6 +16,7 @@ module GitlabQuality
|
|
12
16
|
# - Takes a project where failure issues should be created
|
13
17
|
# - Find issue by title (with test description or test file), then further filter by stack trace, then pick the better-matching one
|
14
18
|
# - Add the failed job to the issue description, and update labels
|
19
|
+
# - Can group similar failures together when group_similar option is enabled
|
15
20
|
class RelateFailureIssue < ReportAsIssue
|
16
21
|
include TestTooling::Concerns::FindSetDri
|
17
22
|
include Concerns::GroupAndCategoryLabels
|
@@ -63,6 +68,7 @@ module GitlabQuality
|
|
63
68
|
base_issue_labels: nil,
|
64
69
|
exclude_labels_for_search: nil,
|
65
70
|
metrics_files: [],
|
71
|
+
group_similar: false,
|
66
72
|
**kwargs)
|
67
73
|
super
|
68
74
|
@max_diff_ratio = max_diff_ratio.to_f
|
@@ -72,19 +78,106 @@ module GitlabQuality
|
|
72
78
|
@issue_type = 'issue'
|
73
79
|
@commented_issue_list = Set.new
|
74
80
|
@metrics_files = Array(metrics_files)
|
81
|
+
@group_similar = group_similar
|
75
82
|
end
|
76
83
|
|
77
84
|
private
|
78
85
|
|
79
|
-
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files, :ops_gitlab_client
|
86
|
+
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files, :ops_gitlab_client, :group_similar
|
80
87
|
|
81
88
|
def run!
|
82
89
|
puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
83
90
|
|
91
|
+
if group_similar
|
92
|
+
run_with_grouping!
|
93
|
+
else
|
94
|
+
TestResults::Builder.new(token: token, project: project, file_glob: files).test_results_per_file do |test_results|
|
95
|
+
puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
|
96
|
+
process_test_results(test_results)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def grouper
|
102
|
+
@grouper ||= GitlabQuality::TestTooling::Report::GroupIssues::GroupResultsInIssues.new(
|
103
|
+
gitlab: gitlab,
|
104
|
+
config: grouper_config
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
def run_with_grouping!
|
109
|
+
Runtime::Logger.info "=> Grouping similar failures where possible"
|
110
|
+
|
111
|
+
all_test_results = collect_all_test_results
|
112
|
+
return if all_test_results.empty?
|
113
|
+
|
114
|
+
Runtime::Logger.info "=> Processing #{all_test_results.count} failures with GroupResultsInIssues"
|
115
|
+
|
116
|
+
failure_data = convert_test_results_to_failure_data(all_test_results)
|
117
|
+
|
118
|
+
grouper.process_failures(failure_data)
|
119
|
+
grouper.process_issues
|
120
|
+
end
|
121
|
+
|
122
|
+
def collect_all_test_results
|
123
|
+
all_test_results = []
|
124
|
+
|
84
125
|
TestResults::Builder.new(token: token, project: project, file_glob: files).test_results_per_file do |test_results|
|
85
|
-
|
86
|
-
|
126
|
+
Runtime::Logger.info "=> Collecting #{test_results.count} tests from #{test_results.path}"
|
127
|
+
all_test_results.concat(test_results.select(&:failures?))
|
87
128
|
end
|
129
|
+
|
130
|
+
all_test_results
|
131
|
+
end
|
132
|
+
|
133
|
+
def convert_test_results_to_failure_data(test_results)
|
134
|
+
test_results.map do |test|
|
135
|
+
{
|
136
|
+
description: test.name,
|
137
|
+
full_description: test.name,
|
138
|
+
file_path: test.relative_file,
|
139
|
+
line_number: extract_line_number(test),
|
140
|
+
exception: extract_exception_data(test),
|
141
|
+
ci_job_url: test.ci_job_url,
|
142
|
+
testcase: extract_test_id_or_name(test),
|
143
|
+
product_group: extract_product_group(test),
|
144
|
+
level: extract_level(test)
|
145
|
+
}
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def extract_line_number(test)
|
150
|
+
test.respond_to?(:line_number) ? test.line_number : nil
|
151
|
+
end
|
152
|
+
|
153
|
+
def extract_exception_data(test)
|
154
|
+
{
|
155
|
+
'message' => test.failures.first&.dig('message') || test.full_stacktrace || 'Unknown error'
|
156
|
+
}
|
157
|
+
end
|
158
|
+
|
159
|
+
def extract_product_group(test)
|
160
|
+
test.respond_to?(:product_group) ? test.product_group : nil
|
161
|
+
end
|
162
|
+
|
163
|
+
def extract_level(test)
|
164
|
+
test.respond_to?(:level) ? test.level : nil
|
165
|
+
end
|
166
|
+
|
167
|
+
def grouper_config
|
168
|
+
{
|
169
|
+
thresholds: {
|
170
|
+
min_failures_to_group: 2
|
171
|
+
}
|
172
|
+
}
|
173
|
+
end
|
174
|
+
|
175
|
+
def extract_test_id_or_name(test)
|
176
|
+
return test.example_id if test.respond_to?(:example_id)
|
177
|
+
return test.id if test.respond_to?(:id)
|
178
|
+
return test.name if test.respond_to?(:name)
|
179
|
+
|
180
|
+
"#{test.relative_file}:#{test.respond_to?(:line_number) ? test.line_number : 'unknown'}"
|
88
181
|
end
|
89
182
|
|
90
183
|
def new_issue_labels(test)
|
@@ -469,10 +562,40 @@ module GitlabQuality
|
|
469
562
|
end
|
470
563
|
|
471
564
|
def new_issue_assignee_id(test)
|
472
|
-
|
565
|
+
assignee_id = try_feature_category_assignment(test)
|
566
|
+
return assignee_id if assignee_id
|
567
|
+
|
568
|
+
try_product_group_assignment(test)
|
569
|
+
end
|
570
|
+
|
571
|
+
def try_feature_category_assignment(test)
|
572
|
+
unless test.respond_to?(:feature_category) && test.feature_category?
|
573
|
+
Runtime::Logger.info("No feature_category found for DRI assignment")
|
574
|
+
return
|
575
|
+
end
|
576
|
+
|
577
|
+
labels_inference = GitlabQuality::TestTooling::LabelsInference.new
|
578
|
+
product_group = labels_inference.product_group_from_feature_category(test.feature_category)
|
579
|
+
|
580
|
+
unless product_group
|
581
|
+
Runtime::Logger.warn("Could not map feature_category '#{test.feature_category}' to product_group")
|
582
|
+
return
|
583
|
+
end
|
584
|
+
|
585
|
+
dri = test_dri(product_group, test.stage, test.section)
|
586
|
+
Runtime::Logger.info("Assigning #{dri} as DRI for the issue (via feature_category).")
|
587
|
+
|
588
|
+
gitlab.find_user_id(username: dri)
|
589
|
+
end
|
590
|
+
|
591
|
+
def try_product_group_assignment(test)
|
592
|
+
unless test.respond_to?(:product_group) && test.product_group?
|
593
|
+
Runtime::Logger.info("No product_group found for DRI assignment")
|
594
|
+
return
|
595
|
+
end
|
473
596
|
|
474
597
|
dri = test_dri(test.product_group, test.stage, test.section)
|
475
|
-
|
598
|
+
Runtime::Logger.info("Assigning #{dri} as DRI for the issue (via product_group).")
|
476
599
|
|
477
600
|
gitlab.find_user_id(username: dri)
|
478
601
|
end
|
@@ -8,7 +8,7 @@ module GitlabQuality
|
|
8
8
|
class TestMetaUpdater
|
9
9
|
include TestTooling::Concerns::FindSetDri
|
10
10
|
|
11
|
-
attr_reader :project, :ref, :report_issue, :processed_commits
|
11
|
+
attr_reader :project, :ref, :report_issue, :processed_commits, :token, :specs_file, :dry_run, :processor
|
12
12
|
|
13
13
|
TEST_PLATFORM_MAINTAINERS_SLACK_CHANNEL_ID = 'C0437FV9KBN' # test-platform-maintainers
|
14
14
|
|
@@ -269,12 +269,15 @@ module GitlabQuality
|
|
269
269
|
# Fetch the id for the dri of the product group and stage
|
270
270
|
# The first item returned is the id of the assignee and the second item is the handle
|
271
271
|
#
|
272
|
-
# @param [
|
272
|
+
# @param [Hash] test object
|
273
273
|
# @param [String] devops_stage
|
274
|
+
# @param [String] section
|
274
275
|
# @return [Array<Integer, String>]
|
275
|
-
def fetch_dri_id(
|
276
|
-
|
276
|
+
def fetch_dri_id(test, devops_stage, section)
|
277
|
+
product_group = determine_product_group(test)
|
278
|
+
return unless product_group
|
277
279
|
|
280
|
+
assignee_handle = ENV.fetch('QA_TEST_DRI_HANDLE', nil) || test_dri(product_group, devops_stage, section)
|
278
281
|
[user_id_for_username(assignee_handle), assignee_handle]
|
279
282
|
end
|
280
283
|
|
@@ -335,6 +338,16 @@ module GitlabQuality
|
|
335
338
|
label ? %(/label ~"#{label}") : ''
|
336
339
|
end
|
337
340
|
|
341
|
+
# Infers the group label from the provided feature category
|
342
|
+
#
|
343
|
+
# @param [String] feature_category feature category
|
344
|
+
# @return [String]
|
345
|
+
def label_from_feature_category(feature_category)
|
346
|
+
labels = labels_inference.infer_labels_from_feature_category(feature_category)
|
347
|
+
group_label = labels.find { |label| label.start_with?('group::') }
|
348
|
+
group_label ? %(/label ~"#{group_label}") : ''
|
349
|
+
end
|
350
|
+
|
338
351
|
# Returns the link to the Grafana dashboard for single spec metrics
|
339
352
|
#
|
340
353
|
# @param [String] example_name the full example name
|
@@ -344,10 +357,6 @@ module GitlabQuality
|
|
344
357
|
base_url + CGI.escape(example_name)
|
345
358
|
end
|
346
359
|
|
347
|
-
private
|
348
|
-
|
349
|
-
attr_reader :token, :specs_file, :dry_run, :processor
|
350
|
-
|
351
360
|
# Returns any test description string within single or double quotes
|
352
361
|
#
|
353
362
|
# @param [String] line the line to check for any quoted string
|
@@ -380,6 +389,27 @@ module GitlabQuality
|
|
380
389
|
def labels_inference
|
381
390
|
@labels_inference ||= GitlabQuality::TestTooling::LabelsInference.new
|
382
391
|
end
|
392
|
+
|
393
|
+
private
|
394
|
+
|
395
|
+
def determine_product_group(test)
|
396
|
+
return map_feature_category_to_product_group(test) if has_feature_category?(test)
|
397
|
+
return test.product_group if has_product_group?(test)
|
398
|
+
|
399
|
+
nil
|
400
|
+
end
|
401
|
+
|
402
|
+
def has_feature_category?(test)
|
403
|
+
test.respond_to?(:feature_category) && test.feature_category?
|
404
|
+
end
|
405
|
+
|
406
|
+
def has_product_group?(test)
|
407
|
+
test.respond_to?(:product_group) && test.product_group?
|
408
|
+
end
|
409
|
+
|
410
|
+
def map_feature_category_to_product_group(test)
|
411
|
+
labels_inference.product_group_from_feature_category(test.feature_category)
|
412
|
+
end
|
383
413
|
end
|
384
414
|
end
|
385
415
|
end
|
@@ -7,6 +7,8 @@ module GitlabQuality
|
|
7
7
|
module TestTooling
|
8
8
|
Error = Class.new(StandardError)
|
9
9
|
loader = Zeitwerk::Loader.new
|
10
|
+
loader.push_dir(__dir__.to_s, namespace: GitlabQuality)
|
11
|
+
loader.ignore("#{__dir__}/test_tooling.rb")
|
10
12
|
loader.push_dir("#{__dir__}/test_tooling", namespace: GitlabQuality::TestTooling)
|
11
13
|
loader.ignore("#{__dir__}/test_tooling/version.rb")
|
12
14
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gitlab_quality-test_tooling
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.18.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab Quality
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-08-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: climate_control
|
@@ -522,6 +522,17 @@ files:
|
|
522
522
|
- lib/gitlab_quality/test_tooling/report/feature_readiness/report_on_epic.rb
|
523
523
|
- lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb
|
524
524
|
- lib/gitlab_quality/test_tooling/report/generate_test_session.rb
|
525
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/error_message_normalizer.rb
|
526
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/error_pattern_matcher.rb
|
527
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/failure_processor.rb
|
528
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/group_results_in_issues.rb
|
529
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/incident_checker.rb
|
530
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/issue_base.rb
|
531
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/issue_creator.rb
|
532
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/issue_finder.rb
|
533
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/issue_formatter.rb
|
534
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/issue_manager.rb
|
535
|
+
- lib/gitlab_quality/test_tooling/report/group_issues/issue_updater.rb
|
525
536
|
- lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb
|
526
537
|
- lib/gitlab_quality/test_tooling/report/issue_logger.rb
|
527
538
|
- lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb
|