gitlab_quality-test_tooling 2.16.0 → 2.25.1
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/README.md +1 -1
- data/exe/epic-readiness-notification +58 -0
- data/exe/post-to-slack +4 -0
- data/exe/relate-failure-issue +9 -0
- data/exe/test-coverage +113 -0
- data/lib/gitlab_quality/test_tooling/click_house/client.rb +111 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +77 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +158 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +62 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +109 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +73 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +82 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +91 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb +43 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/test_map.rb +93 -0
- data/lib/gitlab_quality/test_tooling/code_coverage/utils.rb +18 -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/concerns/results_reporter.rb +1 -1
- 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 +176 -5
- data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +0 -1
- 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/slack/post_to_slack.rb +103 -3
- 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 +61 -36
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +125 -80
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +95 -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 +82 -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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module CodeCoverage
|
|
8
|
+
class TestMap
|
|
9
|
+
SEPARATOR = '/'
|
|
10
|
+
MARKER = 1
|
|
11
|
+
|
|
12
|
+
# @param [Hash] compact_map A nested hash structure where keys are
|
|
13
|
+
# source files and values are tree structures with test file paths
|
|
14
|
+
# @example Example of compact_map for a file tested by 3 spec files
|
|
15
|
+
# {
|
|
16
|
+
# "app/models/user.rb" => {
|
|
17
|
+
# "spec" => {
|
|
18
|
+
# "models" => {
|
|
19
|
+
# "user_spec.rb" => 1 # MARKER (1) indicates a leaf node
|
|
20
|
+
# }
|
|
21
|
+
# },
|
|
22
|
+
# "ee" => {
|
|
23
|
+
# "spec" => {
|
|
24
|
+
# "lib" => {
|
|
25
|
+
# "ee" => {
|
|
26
|
+
# "gitlab" => {
|
|
27
|
+
# "background_migration" => {
|
|
28
|
+
# "delete_invalid_epic_issues_spec.rb"=>1,
|
|
29
|
+
# "backfill_security_policies_spec.rb"=>1
|
|
30
|
+
# }
|
|
31
|
+
# }
|
|
32
|
+
# }
|
|
33
|
+
# }
|
|
34
|
+
# }
|
|
35
|
+
# }
|
|
36
|
+
# }
|
|
37
|
+
# }
|
|
38
|
+
def initialize(compact_map)
|
|
39
|
+
@compact_map = compact_map
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash<String, Array<String>>] Source files mapped to all test
|
|
43
|
+
# files testing them
|
|
44
|
+
# @example Return value
|
|
45
|
+
# {
|
|
46
|
+
# "path/to/file1.rb" => [
|
|
47
|
+
# "spec/path/to/file1_spec.rb",
|
|
48
|
+
# "spec/path/to/another/file1_spec.rb"
|
|
49
|
+
# ],
|
|
50
|
+
# ...
|
|
51
|
+
# }
|
|
52
|
+
def source_to_tests
|
|
53
|
+
@source_to_tests ||= @compact_map.transform_values { |tree| traverse(tree).to_a.uniq }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Hash<String, Array<String>>] Test files mapped to all source
|
|
57
|
+
# files tested by them
|
|
58
|
+
# @example Return value
|
|
59
|
+
# {
|
|
60
|
+
# "spec/path/to/file1_spec.rb" => [
|
|
61
|
+
# "path/to/file1.rb",
|
|
62
|
+
# "path/to/file2.rb"
|
|
63
|
+
# ],
|
|
64
|
+
# ...
|
|
65
|
+
# }
|
|
66
|
+
def test_to_sources
|
|
67
|
+
@test_to_sources ||= begin
|
|
68
|
+
test_to_sources = Hash.new { |hash, key| hash[key] = [] }
|
|
69
|
+
|
|
70
|
+
@compact_map.each do |source_file, tree|
|
|
71
|
+
traverse(tree).to_a.each do |test_file|
|
|
72
|
+
test_to_sources[test_file] << source_file
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test_to_sources
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def traverse(tree, segments = [], &block)
|
|
83
|
+
return to_enum(__method__, tree, segments) unless block
|
|
84
|
+
return yield segments.join(SEPARATOR) if tree == MARKER && !segments.empty?
|
|
85
|
+
|
|
86
|
+
tree.each do |key, value|
|
|
87
|
+
traverse(value, segments + [key], &block)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module GitlabQuality
|
|
7
|
+
module TestTooling
|
|
8
|
+
module CodeCoverage
|
|
9
|
+
module Utils
|
|
10
|
+
def exponential_delay_with_jitter(attempt)
|
|
11
|
+
exponential_delay = (2**(attempt - 1))
|
|
12
|
+
jitter = rand # 0-1 seconds
|
|
13
|
+
exponential_delay + jitter
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -21,7 +21,7 @@ module GitlabQuality
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def has_operational_readiness_issue_linked?(linked_issue_iids, issue_client)
|
|
24
|
-
linked_issues(linked_issue_iids, issue_client).any? { |issue|
|
|
24
|
+
linked_issues(linked_issue_iids, issue_client).any? { |issue| issue.labels.intersect?([OPERATIONAL_READINESS_CHECKLIST_LABEL]) }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def linked_issues(linked_issue_iids, issue_client)
|
|
@@ -118,6 +118,17 @@ module GitlabQuality
|
|
|
118
118
|
labels.first.id
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
+
def ids_for_group_labels(labels, group_labels_client)
|
|
122
|
+
labels.filter_map { |label| get_id_for_group_label(label, group_labels_client) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get_id_for_group_label(label_name, group_labels_client)
|
|
126
|
+
labels = group_labels_client.group_labels(options: { search: label_name })
|
|
127
|
+
return nil if labels.empty?
|
|
128
|
+
|
|
129
|
+
labels.first.id
|
|
130
|
+
end
|
|
131
|
+
|
|
121
132
|
def extract_id_from_gid(gid)
|
|
122
133
|
gid.to_s.split('/').last.to_i
|
|
123
134
|
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module GitlabQuality
|
|
6
|
+
module TestTooling
|
|
7
|
+
module FeatureReadiness
|
|
8
|
+
class EpicReadinessNotifier
|
|
9
|
+
include Concerns::WorkItemConcern
|
|
10
|
+
|
|
11
|
+
FEATURE_READINESS_NOTIFICATION_ID = '<!-- FEATURE READINESS NOTIFICATION -->'
|
|
12
|
+
FEATURE_READINESS_CANDIDATE_LABEL = 'feature readiness candidate'
|
|
13
|
+
|
|
14
|
+
def initialize(token:, epic_urls: nil, epic_urls_file: nil, message: nil, dry_run: false)
|
|
15
|
+
@token = token
|
|
16
|
+
@epic_urls = epic_urls
|
|
17
|
+
@epic_urls_file = epic_urls_file
|
|
18
|
+
@custom_message = message
|
|
19
|
+
@dry_run = dry_run
|
|
20
|
+
@results = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def invoke!
|
|
24
|
+
urls = collect_epic_urls
|
|
25
|
+
print_header(urls.size)
|
|
26
|
+
process_all_urls(urls)
|
|
27
|
+
print_summary
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :token, :epic_urls, :epic_urls_file, :custom_message, :dry_run, :results
|
|
33
|
+
|
|
34
|
+
def print_header(url_count)
|
|
35
|
+
puts "Processing #{url_count} epic URL(s)..."
|
|
36
|
+
puts "Dry run mode: #{dry_run ? 'enabled' : 'disabled'}"
|
|
37
|
+
puts
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def process_all_urls(urls)
|
|
41
|
+
urls.each_with_index do |url, index|
|
|
42
|
+
puts "[#{index + 1}/#{urls.size}] Processing: #{url}"
|
|
43
|
+
process_single_url(url)
|
|
44
|
+
puts
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def process_single_url(url)
|
|
49
|
+
result = process_epic_url(url)
|
|
50
|
+
@results << result
|
|
51
|
+
print_result(result)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
error_result = { url: url, success: false, error: e.message }
|
|
54
|
+
@results << error_result
|
|
55
|
+
puts " ❌ Error: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def print_result(result)
|
|
59
|
+
if result[:success]
|
|
60
|
+
author_info = result[:author] ? (result[:author]).to_s : "epic (no author assigned)"
|
|
61
|
+
puts " ✅ Successfully notified #{author_info} and added label '#{FEATURE_READINESS_CANDIDATE_LABEL}' to epic: #{result[:title]}"
|
|
62
|
+
else
|
|
63
|
+
puts " ❌ Failed: #{result[:error]}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect_epic_urls
|
|
68
|
+
urls = []
|
|
69
|
+
urls.concat(epic_urls) if epic_urls
|
|
70
|
+
urls.concat(collect_urls_from_file) if epic_urls_file
|
|
71
|
+
validate_urls_present(urls)
|
|
72
|
+
urls.uniq
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def collect_urls_from_file
|
|
76
|
+
validate_file_exists
|
|
77
|
+
File.readlines(epic_urls_file, chomp: true)
|
|
78
|
+
.map(&:strip)
|
|
79
|
+
.reject(&:empty?)
|
|
80
|
+
.reject { |line| line.start_with?('#') }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_file_exists
|
|
84
|
+
return if File.exist?(epic_urls_file)
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "Epic URLs file not found: #{epic_urls_file}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_urls_present(urls)
|
|
90
|
+
return unless urls.empty?
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "No epic URLs provided"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def process_epic_url(url)
|
|
96
|
+
epic_info = parse_epic_url(url)
|
|
97
|
+
client = work_items_client_for_group(epic_info[:group])
|
|
98
|
+
epic = fetch_and_validate_epic(epic_info, client, url)
|
|
99
|
+
|
|
100
|
+
return epic if epic.key?(:error)
|
|
101
|
+
|
|
102
|
+
existing_note = existing_note_containing_text(FEATURE_READINESS_NOTIFICATION_ID, epic_info[:epic_iid], client)
|
|
103
|
+
return build_existing_notification_result(url, epic) if existing_note
|
|
104
|
+
|
|
105
|
+
post_notification_and_label(epic, epic_info, client) unless dry_run
|
|
106
|
+
build_success_result(url, epic, epic_info)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fetch_and_validate_epic(epic_info, client, url)
|
|
110
|
+
epic = fetch_work_item(epic_info[:epic_iid], client, [:notes])
|
|
111
|
+
return { url: url, success: false, error: "Epic not found or not accessible" } unless epic
|
|
112
|
+
return { url: url, success: false, error: "Work item is not an epic (type: #{epic[:workItemType][:name]})" } unless epic[:workItemType][:name] == "Epic"
|
|
113
|
+
|
|
114
|
+
epic
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_existing_notification_result(url, epic)
|
|
118
|
+
{
|
|
119
|
+
url: url,
|
|
120
|
+
success: false,
|
|
121
|
+
error: "Notification already exists",
|
|
122
|
+
title: epic[:title],
|
|
123
|
+
author: epic[:author] ? epic[:author][:username] : nil
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def post_notification_and_label(epic, epic_info, client)
|
|
128
|
+
message = build_notification_message(epic)
|
|
129
|
+
|
|
130
|
+
# Create the discussion/comment
|
|
131
|
+
client.post(
|
|
132
|
+
<<~GQL
|
|
133
|
+
mutation CreateDiscussion {
|
|
134
|
+
createDiscussion(input: {noteableId: "#{epic[:id]}", body: #{message.inspect}}) {
|
|
135
|
+
clientMutationId
|
|
136
|
+
errors
|
|
137
|
+
note {
|
|
138
|
+
id
|
|
139
|
+
body
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
GQL
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Apply group label if it exists
|
|
147
|
+
apply_group_label_if_exists(epic, epic_info, client)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_success_result(url, epic, epic_info)
|
|
151
|
+
{
|
|
152
|
+
url: url,
|
|
153
|
+
success: true,
|
|
154
|
+
title: epic[:title],
|
|
155
|
+
author: epic[:author] ? epic[:author][:username] : nil,
|
|
156
|
+
group: epic_info[:group],
|
|
157
|
+
epic_iid: epic_info[:epic_iid]
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_epic_url(url)
|
|
162
|
+
uri = URI.parse(url)
|
|
163
|
+
path_parts = uri.path.split('/')
|
|
164
|
+
groups_index, epics_index = find_url_indices(path_parts, url)
|
|
165
|
+
group_path, epic_iid = extract_group_and_epic_id(path_parts, groups_index, epics_index, url)
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
host: "#{uri.scheme}://#{uri.host}",
|
|
169
|
+
group: group_path,
|
|
170
|
+
epic_iid: epic_iid,
|
|
171
|
+
url: url
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def find_url_indices(path_parts, url)
|
|
176
|
+
groups_index = path_parts.index('groups')
|
|
177
|
+
epics_index = path_parts.index('epics')
|
|
178
|
+
|
|
179
|
+
raise ArgumentError, "Invalid epic URL format: #{url}" unless groups_index && epics_index && epics_index > groups_index
|
|
180
|
+
|
|
181
|
+
[groups_index, epics_index]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def extract_group_and_epic_id(path_parts, groups_index, epics_index, url)
|
|
185
|
+
group_parts = path_parts[(groups_index + 1)...(epics_index - 1)]
|
|
186
|
+
group_path = group_parts.join('/')
|
|
187
|
+
epic_iid = path_parts[epics_index + 1]
|
|
188
|
+
|
|
189
|
+
validate_extracted_parts(group_path, epic_iid, url)
|
|
190
|
+
[group_path, epic_iid]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_extracted_parts(group_path, epic_iid, url)
|
|
194
|
+
return if group_path && epic_iid&.match?(/^\d+$/)
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "Could not extract group and epic IID from URL: #{url}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def work_items_client_for_group(group)
|
|
200
|
+
client_class = dry_run ? GitlabClient::WorkItemsDryClient : GitlabClient::WorkItemsClient
|
|
201
|
+
client_class.new(token: token, project: nil, group: group)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def group_labels_client_for_group(group)
|
|
205
|
+
GitlabClient::GroupLabelsClient.new(token: token, group: group)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def apply_group_label_if_exists(epic, epic_info, client)
|
|
209
|
+
group_labels_client = group_labels_client_for_group(epic_info[:group])
|
|
210
|
+
label_ids = ids_for_group_labels([FEATURE_READINESS_CANDIDATE_LABEL], group_labels_client)
|
|
211
|
+
|
|
212
|
+
if label_ids.empty?
|
|
213
|
+
puts "Warning: Group label '#{FEATURE_READINESS_CANDIDATE_LABEL}' not found in group #{epic_info[:group]}. Skipping label application."
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
puts "Applying group label '#{FEATURE_READINESS_CANDIDATE_LABEL}' to epic..." unless dry_run
|
|
218
|
+
add_labels(label_ids, epic[:id], client) unless dry_run
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
puts "Warning: Could not apply group label '#{FEATURE_READINESS_CANDIDATE_LABEL}': #{e.message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_notification_message(epic)
|
|
224
|
+
author_mention = build_author_mention(epic)
|
|
225
|
+
|
|
226
|
+
message = custom_message || default_message_template
|
|
227
|
+
|
|
228
|
+
# Replace placeholders in the message
|
|
229
|
+
message = message.gsub('{author}', author_mention)
|
|
230
|
+
.gsub('{epic_title}', epic[:title])
|
|
231
|
+
.gsub('{epic_url}', epic[:webUrl])
|
|
232
|
+
|
|
233
|
+
# Add the notification ID for tracking
|
|
234
|
+
"#{FEATURE_READINESS_NOTIFICATION_ID}\n#{message}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def build_author_mention(epic)
|
|
238
|
+
return '' unless epic[:author] && epic[:author][:username]
|
|
239
|
+
|
|
240
|
+
"@#{epic[:author][:username]}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def default_message_template
|
|
244
|
+
<<~MESSAGE
|
|
245
|
+
### Platform Readiness Enablement Process (PREP) Potentially Required
|
|
246
|
+
|
|
247
|
+
{author} The feature **"{epic_title}"** has been selected as a potential candidate for Platform Readiness Enablement Process.
|
|
248
|
+
|
|
249
|
+
### What is PREP?
|
|
250
|
+
The **Platform Readiness Enablement Process** (PREP) is a comprehensive process
|
|
251
|
+
that guides product teams through evaluating GitLab features for readiness
|
|
252
|
+
across GitLab.com, GitLab Dedicated, and GitLab Self-Managed deployment platforms.
|
|
253
|
+
|
|
254
|
+
Please note that a completed and approved PREP is **mandatory** for qualified features to reach GA status.
|
|
255
|
+
|
|
256
|
+
## Next Steps:
|
|
257
|
+
1. Review the [handbook](https://internal.gitlab.com/handbook/product/platforms/feature-readiness-assessment/#when-prep-is-required) to confirm if you feature qualifies for PREP.
|
|
258
|
+
2. If it does, start the assessment by following the guidelines in the [documentation](https://gitlab.com/gitlab-org/architecture/readiness).
|
|
259
|
+
3. Reach out to the Feature Readiness team on Slack at `#g_feature_readiness` if you have questions.
|
|
260
|
+
|
|
261
|
+
Please react with ✅ if this classification is relevant or ❌ if not relevant to help improve our system.
|
|
262
|
+
|
|
263
|
+
MESSAGE
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def print_summary
|
|
267
|
+
successful, failed = calculate_summary_counts
|
|
268
|
+
print_summary_header
|
|
269
|
+
print_summary_stats(successful, failed)
|
|
270
|
+
print_failed_urls(failed) if failed.positive?
|
|
271
|
+
print_completion_message
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def calculate_summary_counts
|
|
275
|
+
successful = results.count { |r| r[:success] }
|
|
276
|
+
failed = results.count { |r| !r[:success] }
|
|
277
|
+
[successful, failed]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def print_summary_header
|
|
281
|
+
puts "=" * 50
|
|
282
|
+
puts "SUMMARY"
|
|
283
|
+
puts "=" * 50
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def print_summary_stats(successful, failed)
|
|
287
|
+
puts "Total processed: #{results.size}"
|
|
288
|
+
puts "Successful: #{successful}"
|
|
289
|
+
puts "Failed: #{failed}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def print_failed_urls(_failed)
|
|
293
|
+
puts
|
|
294
|
+
puts "Failed URLs:"
|
|
295
|
+
results.select { |r| !r[:success] }.each do |result|
|
|
296
|
+
puts " - #{result[:url]}: #{result[:error]}"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def print_completion_message
|
|
301
|
+
puts
|
|
302
|
+
message = dry_run ? "Dry run completed - no notifications were sent or labels added." : "Notification process completed."
|
|
303
|
+
puts message
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -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
|