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.
Files changed (67) 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/README.md +1 -1
  6. data/exe/epic-readiness-notification +58 -0
  7. data/exe/post-to-slack +4 -0
  8. data/exe/relate-failure-issue +9 -0
  9. data/exe/test-coverage +113 -0
  10. data/lib/gitlab_quality/test_tooling/click_house/client.rb +111 -0
  11. data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +77 -0
  12. data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +158 -0
  13. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +62 -0
  14. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +109 -0
  15. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +73 -0
  16. data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +82 -0
  17. data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +91 -0
  18. data/lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb +43 -0
  19. data/lib/gitlab_quality/test_tooling/code_coverage/test_map.rb +93 -0
  20. data/lib/gitlab_quality/test_tooling/code_coverage/utils.rb +18 -0
  21. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +1 -1
  22. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +11 -0
  23. data/lib/gitlab_quality/test_tooling/feature_readiness/epic_readiness_notifier.rb +308 -0
  24. data/lib/gitlab_quality/test_tooling/gcs_tools.rb +49 -0
  25. data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +2 -9
  26. data/lib/gitlab_quality/test_tooling/gitlab_client/group_labels_client.rb +34 -0
  27. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +1 -1
  28. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +2 -2
  29. data/lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb +1 -1
  30. data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +1 -1
  31. data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +2 -2
  32. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +2 -2
  33. data/lib/gitlab_quality/test_tooling/report/group_issues/error_message_normalizer.rb +49 -0
  34. data/lib/gitlab_quality/test_tooling/report/group_issues/error_pattern_matcher.rb +36 -0
  35. data/lib/gitlab_quality/test_tooling/report/group_issues/failure_processor.rb +73 -0
  36. data/lib/gitlab_quality/test_tooling/report/group_issues/group_results_in_issues.rb +48 -0
  37. data/lib/gitlab_quality/test_tooling/report/group_issues/incident_checker.rb +61 -0
  38. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_base.rb +48 -0
  39. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_creator.rb +44 -0
  40. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_finder.rb +81 -0
  41. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_formatter.rb +83 -0
  42. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_manager.rb +33 -0
  43. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_updater.rb +87 -0
  44. data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +6 -3
  45. data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
  46. data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
  47. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +176 -5
  48. data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +0 -1
  49. data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +2 -1
  50. data/lib/gitlab_quality/test_tooling/runtime/env.rb +9 -4
  51. data/lib/gitlab_quality/test_tooling/slack/post_to_slack.rb +103 -3
  52. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +1 -1
  53. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +1 -1
  54. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +1 -1
  55. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +1 -1
  56. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +39 -11
  57. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +115 -15
  58. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +61 -36
  59. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +125 -80
  60. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +95 -0
  61. data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +6 -2
  62. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  63. data/lib/gitlab_quality/test_tooling.rb +3 -0
  64. metadata +82 -55
  65. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/log_test_metrics.rb +0 -117
  66. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +0 -49
  67. 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| (issue.labels & [OPERATIONAL_READINESS_CHECKLIST_LABEL]).any? }
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 = 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}"
@@ -36,7 +36,7 @@ module GitlabQuality
36
36
  end
37
37
 
38
38
  def new_issue_labels(_test)
39
- %w[E2E status::automated]
39
+ %w[E2E status::automated suppress-contributor-links]
40
40
  end
41
41
 
42
42
  def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
@@ -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