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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/.tool-versions +1 -1
- data/Gemfile.lock +30 -28
- data/exe/epic-readiness-notification +58 -0
- data/exe/relate-failure-issue +5 -0
- data/lib/gitlab_quality/test_tooling/click_house/client.rb +111 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +1 -1
- data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +11 -0
- data/lib/gitlab_quality/test_tooling/feature_readiness/epic_readiness_notifier.rb +308 -0
- data/lib/gitlab_quality/test_tooling/gcs_tools.rb +49 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +2 -9
- data/lib/gitlab_quality/test_tooling/gitlab_client/group_labels_client.rb +34 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +1 -1
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +2 -2
- data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +2 -2
- data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +2 -2
- 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 +81 -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/health_problem_reporter.rb +6 -3
- data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +134 -5
- data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +2 -1
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +9 -4
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +1 -1
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +1 -1
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +1 -1
- data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +1 -1
- data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +39 -11
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +115 -15
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +57 -36
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +124 -80
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +94 -0
- data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +6 -2
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- data/lib/gitlab_quality/test_tooling.rb +3 -0
- metadata +70 -55
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/log_test_metrics.rb +0 -117
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +0 -49
- 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 =
|
|
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:,
|
|
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/
|
|
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.
|
|
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
|