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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d4287c990a56b7a1f4c008c603bbef7c18870eafcca90efce10f78283b1b00d
4
- data.tar.gz: c21e64acec896cb30bfb58940a0b0f0970bea0c263abb26dfcaf9f4af5c05dcc
3
+ metadata.gz: 7db77718644f72fd0a096182fdf76ad10c912002f0770e245ac782a9b238b690
4
+ data.tar.gz: 7b67154f177663fdc2bd19882b128e5eaa3540f20c31c50fddcf73c6661590ff
5
5
  SHA512:
6
- metadata.gz: fa9ab14084eed5ba92413f0d41e913c58cf768d880c173947145ec77f901c562ad1c2076572fd301dfe2e0c0748868537a52f2fd6c178eac6b4454a08c766cfa
7
- data.tar.gz: d4bd76683ac1f52b433a48a897f58093ec3bae18ad849cca0f72fcebe2c6e386d2f6754a5205e2b9c4ae936bcc6a2089d37cbd864dfd956f04ccd7c57621fa3c
6
+ metadata.gz: 0776f0aef2a4076a7a0bfd71c902d57400eea5388982e62583d689038137ed7fe1166368dabfb006999069e32525fd47d752faf92afffa536b9cbe72303c207f
7
+ data.tar.gz: c3145f170b88abebe7e2a6875d94ccca55fccb370972f37c7679479257186a5e2d73eaedb6bb799139c72bfc32ade74f60e1e5cf07aebb5aa7e3bb8621c7a65d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.16.1)
4
+ gitlab_quality-test_tooling (2.18.0)
5
5
  activesupport (>= 7.0, < 7.3)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
@@ -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', _assignee_id: nil, _due_date: nil, confidential: false)
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
- puts "=> Reporting #{test_results.count} tests in #{test_results.path}"
86
- process_test_results(test_results)
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
- return unless test.product_group?
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
- puts " => Assigning #{dri} as DRI for the issue."
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 [String] product_group
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(product_group, devops_stage, section)
276
- assignee_handle = ENV.fetch('QA_TEST_DRI_HANDLE', nil) || test_dri(product_group, devops_stage, section)
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
@@ -73,6 +73,10 @@ module GitlabQuality
73
73
  product_group != ''
74
74
  end
75
75
 
76
+ def feature_category?
77
+ feature_category && !feature_category.empty?
78
+ end
79
+
76
80
  def failure_issue
77
81
  report['failure_issue']
78
82
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "2.16.1"
5
+ VERSION = "2.18.0"
6
6
  end
7
7
  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.16.1
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-07-24 00:00:00.000000000 Z
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