gitlab_quality-test_tooling 2.16.0 → 2.21.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/.tool-versions +1 -1
  4. data/Gemfile.lock +30 -28
  5. data/exe/epic-readiness-notification +58 -0
  6. data/exe/relate-failure-issue +5 -0
  7. data/lib/gitlab_quality/test_tooling/click_house/client.rb +111 -0
  8. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +1 -1
  9. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +11 -0
  10. data/lib/gitlab_quality/test_tooling/feature_readiness/epic_readiness_notifier.rb +308 -0
  11. data/lib/gitlab_quality/test_tooling/gcs_tools.rb +49 -0
  12. data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +2 -9
  13. data/lib/gitlab_quality/test_tooling/gitlab_client/group_labels_client.rb +34 -0
  14. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +1 -1
  15. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +2 -2
  16. data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +1 -1
  17. data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +2 -2
  18. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +2 -2
  19. data/lib/gitlab_quality/test_tooling/report/group_issues/error_message_normalizer.rb +49 -0
  20. data/lib/gitlab_quality/test_tooling/report/group_issues/error_pattern_matcher.rb +36 -0
  21. data/lib/gitlab_quality/test_tooling/report/group_issues/failure_processor.rb +73 -0
  22. data/lib/gitlab_quality/test_tooling/report/group_issues/group_results_in_issues.rb +48 -0
  23. data/lib/gitlab_quality/test_tooling/report/group_issues/incident_checker.rb +61 -0
  24. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_base.rb +48 -0
  25. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_creator.rb +44 -0
  26. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_finder.rb +81 -0
  27. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_formatter.rb +83 -0
  28. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_manager.rb +33 -0
  29. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_updater.rb +87 -0
  30. data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +6 -3
  31. data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
  32. data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
  33. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +134 -5
  34. data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +2 -1
  35. data/lib/gitlab_quality/test_tooling/runtime/env.rb +9 -4
  36. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +1 -1
  37. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +1 -1
  38. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +1 -1
  39. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +1 -1
  40. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +39 -11
  41. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +115 -15
  42. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +57 -36
  43. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +124 -80
  44. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +94 -0
  45. data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +6 -2
  46. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  47. data/lib/gitlab_quality/test_tooling.rb +3 -0
  48. metadata +70 -55
  49. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/log_test_metrics.rb +0 -117
  50. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +0 -49
  51. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/influxdb_tools.rb +0 -33
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fog/google'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module GcsTools
8
+ class GCSMockClient
9
+ def initialize(*_args, **_kwargs); end
10
+
11
+ def put_object(gcs_bucket, filename, data, **_kwargs)
12
+ Runtime::Logger.info "The #{filename} file would have been pushed to the #{gcs_bucket} bucket, with this content:"
13
+ Runtime::Logger.info data
14
+ end
15
+ end
16
+
17
+ class << self
18
+ # GCS Client
19
+ #
20
+ # @param project_id [String]
21
+ # @param credentials [String]
22
+ # @return [Fog::Storage::Google]
23
+ def gcs_client(project_id:, credentials:, dry_run: false)
24
+ if dry_run
25
+ GCSMockClient.new
26
+ else
27
+ Fog::Storage::Google.new(
28
+ google_project: project_id || raise("Missing Google project_id"),
29
+ **gcs_creds(credentials)
30
+ )
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # GCS Credentials
37
+ #
38
+ # @param credentials [String]
39
+ # @return [Hash]
40
+ def gcs_creds(credentials)
41
+ json_key = credentials || raise('Missing Google credentials')
42
+ return { google_json_key_location: json_key } if File.exist?(json_key)
43
+
44
+ { google_json_key_string: json_key }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -43,14 +43,7 @@ module GitlabQuality
43
43
  return unless ENV['CI_SLACK_WEBHOOK_URL']
44
44
 
45
45
  pipeline = Runtime::Env.pipeline_from_project_name
46
- channel = case pipeline
47
- when "canary"
48
- "e2e-run-production"
49
- when "staging", "staging-canary"
50
- "e2e-run-staging"
51
- else
52
- "e2e-run-#{pipeline}"
53
- end
46
+ channel = Runtime::Env.slack_alerts_channel
54
47
 
55
48
  slack_options = {
56
49
  slack_webhook_url: ENV.fetch('CI_SLACK_WEBHOOK_URL', nil),
@@ -58,7 +51,7 @@ module GitlabQuality
58
51
  username: "GitLab Quality Test Tooling",
59
52
  icon_emoji: ':ci_failing:',
60
53
  message: <<~MSG
61
- An unexpected error occurred while reporting test results in issues.
54
+ Env: #{pipeline}. An unexpected error occurred while reporting test results in issues.
62
55
  The error occurred in job: #{Runtime::Env.ci_job_url}
63
56
  `#{error.class.name} #{error.message}`
64
57
  MSG
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module GitlabClient
6
+ class GroupLabelsClient < GitlabClient
7
+ def initialize(token:, group:, endpoint: nil, **_kwargs)
8
+ @token = token
9
+ @group = group
10
+ @endpoint = endpoint
11
+ end
12
+
13
+ def group_labels(options: {})
14
+ client.group_labels(group, options)
15
+ end
16
+
17
+ def create_group_label(name:, color: '#428BCA', description: nil)
18
+ client.create_group_label(group, name, color, description: description)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :group, :token, :endpoint
24
+
25
+ def client
26
+ @client ||= Gitlab.client(
27
+ endpoint: endpoint || ENV['GITLAB_API_BASE'] || Runtime::Env.gitlab_api_base,
28
+ private_token: token
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -68,7 +68,7 @@ module GitlabQuality
68
68
 
69
69
  def find_issue_notes(iid:)
70
70
  handle_gitlab_client_exceptions do
71
- client.issue_notes(project, iid, order_by: 'created_at', sort: 'asc').auto_paginate
71
+ client.issue_notes(project, iid, order_by: 'created_at', sort: 'asc', activity_filter: 'only_comments').auto_paginate
72
72
  end
73
73
  end
74
74
 
@@ -4,8 +4,8 @@ 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)
8
- attrs = { description: description, labels: labels, confidential: confidential }
7
+ def create_issue(title:, description:, labels:, assignee_id: 'unknown', issue_type: 'issue', confidential: false)
8
+ attrs = { description: description, labels: labels, confidential: confidential, assignee_id: assignee_id }
9
9
 
10
10
  puts "The following #{issue_type} would have been created:"
11
11
  puts "project: #{project}, title: #{title}, attrs: #{attrs}"
@@ -12,7 +12,7 @@ module GitlabQuality
12
12
  # - Add a failure report in the "Failure reports" note
13
13
  class FailedTestIssue < HealthProblemReporter
14
14
  IDENTITY_LABELS = ['test', 'test-health:failures', 'automation:bot-authored'].freeze
15
- NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'failure::new', 'priority::3', 'severity::3', *IDENTITY_LABELS]).freeze
15
+ NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'failure::new', 'priority::3', 'severity::3', 'suppress-contributor-links', *IDENTITY_LABELS]).freeze
16
16
  REPORT_SECTION_HEADER = '#### Failure reports'
17
17
 
18
18
  FAILURE_STACKTRACE_REGEX = %r{(?:(?:.*Failure/Error:(?<stacktrace>.+))|(?<stacktrace>.+))}m
@@ -13,10 +13,10 @@ module GitlabQuality
13
13
  # - Add a flakiness report in the "Flakiness reports" note
14
14
  class FlakyTestIssue < HealthProblemReporter
15
15
  IDENTITY_LABELS = ['test', 'failure::flaky-test', 'test-health:pass-after-retry', 'automation:bot-authored'].freeze
16
- NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'priority::3', 'severity::3', *IDENTITY_LABELS]).freeze
16
+ NEW_ISSUE_LABELS = Set.new(['type::maintenance', 'priority::3', 'severity::3', 'suppress-contributor-links', *IDENTITY_LABELS]).freeze
17
17
  REPORT_SECTION_HEADER = '### Flakiness reports'
18
18
  REPORTS_DOCUMENTATION = <<~DOC
19
- Flaky tests were detected. Please refer to the [Flaky tests reproducibility instructions](https://docs.gitlab.com/ee/development/testing_guide/flaky_tests.html#how-to-reproduce-a-flaky-test-locally)
19
+ Flaky tests were detected. Please refer to the [Flaky tests reproducibility instructions](https://docs.gitlab.com/development/testing_guide/unhealthy_tests/#how-to-reproduce-a-flaky-test-locally)
20
20
  to learn more about how to reproduce them.
21
21
  DOC
22
22
 
@@ -31,9 +31,9 @@ module GitlabQuality
31
31
  tests = tests.select { |test| pipeline_stages.include? test.report["stage"] } unless pipeline_stages.empty?
32
32
 
33
33
  issue = gitlab.create_issue(
34
- title: "#{Time.now.strftime('%Y-%m-%d')} Test session report | #{Runtime::Env.qa_run_type}",
34
+ title: "#{Time.now.to_date.iso8601} Test session report | #{Runtime::Env.qa_run_type}",
35
35
  description: generate_description(tests),
36
- labels: ['automation:bot-authored', 'E2E', 'triage report', pipeline_name_label],
36
+ labels: ['automation:bot-authored', 'E2E', 'triage report', pipeline_name_label, 'suppress-contributor-links'],
37
37
  confidential: confidential
38
38
  )
39
39
 
@@ -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}" if issue&.web_url
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module Report
8
+ module GroupIssues
9
+ class IssueFinder
10
+ DEFAULT_MAX_AGE_HOURS = 24
11
+ ISSUES_PER_PAGE = 50
12
+
13
+ def initialize(client, options = {})
14
+ @client = client
15
+ @options = options
16
+ @token = options[:token]
17
+ @project_id = options[:target_project] || ENV['RESULTS_ISSUE_PROJECT'] || ENV.fetch('CI_PROJECT_ID', nil)
18
+ end
19
+
20
+ def find_existing_issue(grouped_failure)
21
+ find_related_issue_by_fingerprint(
22
+ fingerprint: grouped_failure[:fingerprint],
23
+ max_age_hours: DEFAULT_MAX_AGE_HOURS
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def find_related_issue_by_fingerprint(fingerprint:, max_age_hours:)
30
+ Runtime::Logger.info "Searching for existing issue with fingerprint: #{fingerprint}"
31
+
32
+ begin
33
+ issues = fetch_recent_open_issues(max_age_hours)
34
+ matching_issue = find_issue_with_fingerprint(issues, fingerprint)
35
+ if matching_issue
36
+ Runtime::Logger.info "Found existing issue: ##{matching_issue.iid} - #{matching_issue.title}"
37
+ convert_to_struct(matching_issue)
38
+ end
39
+ rescue Gitlab::Error::Error => e
40
+ Runtime::Logger.error "GitLab API error searching for issues: #{e.message}"
41
+ nil
42
+ rescue StandardError => e
43
+ Runtime::Logger.error "Error searching for existing issues: #{e.message}"
44
+ nil
45
+ end
46
+ end
47
+
48
+ def fetch_recent_open_issues(max_age_hours)
49
+ cutoff_time = Time.now - (max_age_hours * 3600)
50
+
51
+ @client.find_issues(options: {
52
+ state: 'opened',
53
+ created_after: cutoff_time.utc.iso8601,
54
+ per_page: ISSUES_PER_PAGE,
55
+ order_by: 'created_at',
56
+ sort: 'desc'
57
+ })
58
+ end
59
+
60
+ def find_issue_with_fingerprint(issues, fingerprint)
61
+ fingerprint_tag = "grouped-failure-fingerprint:#{fingerprint}"
62
+ issues.find { |issue| issue.description&.include?(fingerprint_tag) }
63
+ end
64
+
65
+ def convert_to_struct(obj)
66
+ return obj unless obj.is_a?(Hash)
67
+
68
+ struct_class = Struct.new(*obj.keys.map(&:to_sym))
69
+ struct = struct_class.new
70
+
71
+ obj.each do |key, value|
72
+ struct[key.to_sym] = value.is_a?(Hash) ? convert_to_struct(value) : value
73
+ end
74
+
75
+ struct
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end