gitlab_quality-test_tooling 2.19.1 → 2.20.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 (28) 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/lib/gitlab_quality/test_tooling/click_house/client.rb +85 -0
  7. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +1 -1
  8. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +11 -0
  9. data/lib/gitlab_quality/test_tooling/feature_readiness/epic_readiness_notifier.rb +308 -0
  10. data/lib/gitlab_quality/test_tooling/gcs_tools.rb +49 -0
  11. data/lib/gitlab_quality/test_tooling/gitlab_client/group_labels_client.rb +34 -0
  12. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +1 -1
  13. data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +3 -3
  14. data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
  15. data/lib/gitlab_quality/test_tooling/runtime/env.rb +5 -3
  16. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +1 -1
  17. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +1 -1
  18. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +1 -1
  19. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +1 -1
  20. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +2 -2
  21. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +88 -15
  22. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +71 -34
  23. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +105 -80
  24. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  25. metadata +58 -55
  26. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/log_test_metrics.rb +0 -117
  27. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +0 -49
  28. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/influxdb_tools.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e14c5a03000218d4c10afb6d388605dac227ab1f436089e40239e87329b66d17
4
- data.tar.gz: 499608ec04650db2746fa234c996c4b37c8fb99a544dbcf3fdb8d2de5768445e
3
+ metadata.gz: c9587ce85da22ee8e77100c3f46e7d46e9bdfe82f129b8de757af427073b51f2
4
+ data.tar.gz: 38318dc4c100c8bd8ba2bbbaa66e1551ce99b12214566d667e822d99eee667ca
5
5
  SHA512:
6
- metadata.gz: 1022bde869ad7de0c7dabc0f1b2084518616ef6987a22593948351f1a2924b8d9532ac9979adee193acaf1a279c1e4f8a197e7b1d60bbee8d884a76f19116d7b
7
- data.tar.gz: 5fc60b6d49fd488f6007a11b585b8c32ccdb224b7b18be44cd559b708353fc42b9c8538234dc235859eb09492c9f336144efc6ac8b8a2dd909ab58b41cbe1386
6
+ metadata.gz: bb75f7abc4c94f6e21d58f4af282763dfe5f671e4f94dae194d99f616a276d5958e6c9ffc3af04d92bc4146cbac02194e9444af9205d213a817a035d90b07c8e
7
+ data.tar.gz: '036382fbbdd1f85e5f01528b8c74214b2eab236519d8b174a7c71b53662896f996a3a0d7a91e21b5afe46494823aa17379a9c2044e769a0413d93626082cdc5c'
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.2.5
1
+ 3.3.9
data/.tool-versions CHANGED
@@ -1,2 +1,2 @@
1
- ruby 3.2.5
1
+ ruby 3.3.9
2
2
  lefthook 1.7.14
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (2.19.1)
4
+ gitlab_quality-test_tooling (2.20.1)
5
5
  activesupport (>= 7.0, < 7.3)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.24, >= 1.24.1)
@@ -128,13 +128,15 @@ GEM
128
128
  danger (>= 8.4.5)
129
129
  danger-gitlab (>= 8.0.0)
130
130
  rake
131
- gitlab-styles (12.0.1)
132
- rubocop (~> 1.62.1)
133
- rubocop-factory_bot (~> 2.25.1)
134
- rubocop-graphql (~> 1.5.0)
135
- rubocop-performance (~> 1.20.2)
136
- rubocop-rails (~> 2.24.0)
137
- rubocop-rspec (~> 2.27.1)
131
+ gitlab-styles (13.1.0)
132
+ rubocop (= 1.71.1)
133
+ rubocop-capybara (~> 2.21.0)
134
+ rubocop-factory_bot (~> 2.26.1)
135
+ rubocop-graphql (~> 1.5.4)
136
+ rubocop-performance (~> 1.21.1)
137
+ rubocop-rails (~> 2.26.0)
138
+ rubocop-rspec (~> 3.0.4)
139
+ rubocop-rspec_rails (~> 2.30.0)
138
140
  google-apis-compute_v1 (0.108.0)
139
141
  google-apis-core (>= 0.15.0, < 2.a)
140
142
  google-apis-core (0.15.1)
@@ -262,7 +264,7 @@ GEM
262
264
  pry (>= 0.13, < 0.15)
263
265
  public_suffix (6.0.1)
264
266
  racc (1.8.1)
265
- rack (3.1.7)
267
+ rack (3.2.1)
266
268
  rainbow (3.1.1)
267
269
  rake (13.2.1)
268
270
  rb-fsevent (0.11.2)
@@ -270,7 +272,7 @@ GEM
270
272
  ffi (~> 1.0)
271
273
  rbs (2.8.4)
272
274
  rchardet (1.8.0)
273
- regexp_parser (2.9.2)
275
+ regexp_parser (2.11.2)
274
276
  representable (3.2.0)
275
277
  declarative (< 0.1.0)
276
278
  trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -311,37 +313,37 @@ GEM
311
313
  rspec-support (3.13.1)
312
314
  rspec_junit_formatter (0.6.0)
313
315
  rspec-core (>= 2, < 4, != 2.12.0)
314
- rubocop (1.62.1)
316
+ rubocop (1.71.1)
315
317
  json (~> 2.3)
316
318
  language_server-protocol (>= 3.17.0)
317
319
  parallel (~> 1.10)
318
320
  parser (>= 3.3.0.2)
319
321
  rainbow (>= 2.2.2, < 4.0)
320
- regexp_parser (>= 1.8, < 3.0)
321
- rexml (>= 3.2.5, < 4.0)
322
- rubocop-ast (>= 1.31.1, < 2.0)
322
+ regexp_parser (>= 2.9.3, < 3.0)
323
+ rubocop-ast (>= 1.38.0, < 2.0)
323
324
  ruby-progressbar (~> 1.7)
324
- unicode-display_width (>= 2.4.0, < 3.0)
325
- rubocop-ast (1.32.3)
325
+ unicode-display_width (>= 2.4.0, < 4.0)
326
+ rubocop-ast (1.40.0)
326
327
  parser (>= 3.3.1.0)
327
328
  rubocop-capybara (2.21.0)
328
329
  rubocop (~> 1.41)
329
- rubocop-factory_bot (2.25.1)
330
- rubocop (~> 1.41)
330
+ rubocop-factory_bot (2.26.1)
331
+ rubocop (~> 1.61)
331
332
  rubocop-graphql (1.5.4)
332
333
  rubocop (>= 1.50, < 2)
333
- rubocop-performance (1.20.2)
334
+ rubocop-performance (1.21.1)
334
335
  rubocop (>= 1.48.1, < 2.0)
335
- rubocop-ast (>= 1.30.0, < 2.0)
336
- rubocop-rails (2.24.1)
336
+ rubocop-ast (>= 1.31.1, < 2.0)
337
+ rubocop-rails (2.26.2)
337
338
  activesupport (>= 4.2.0)
338
339
  rack (>= 1.1)
339
- rubocop (>= 1.33.0, < 2.0)
340
+ rubocop (>= 1.52.0, < 2.0)
340
341
  rubocop-ast (>= 1.31.1, < 2.0)
341
- rubocop-rspec (2.27.1)
342
- rubocop (~> 1.40)
343
- rubocop-capybara (~> 2.17)
344
- rubocop-factory_bot (~> 2.22)
342
+ rubocop-rspec (3.0.5)
343
+ rubocop (~> 1.61)
344
+ rubocop-rspec_rails (2.30.0)
345
+ rubocop (~> 1.61)
346
+ rubocop-rspec (~> 3, >= 3.0.1)
345
347
  ruby-progressbar (1.13.0)
346
348
  sawyer (0.9.2)
347
349
  addressable (>= 2.3.5)
@@ -409,7 +411,7 @@ PLATFORMS
409
411
  DEPENDENCIES
410
412
  climate_control (~> 1.2)
411
413
  gitlab-dangerfiles (~> 3.8)
412
- gitlab-styles (~> 12.0)
414
+ gitlab-styles (~> 13.1)
413
415
  gitlab_quality-test_tooling!
414
416
  guard-rspec (~> 4.7)
415
417
  lefthook (~> 1.3)
@@ -425,4 +427,4 @@ DEPENDENCIES
425
427
  webmock (= 3.7.0)
426
428
 
427
429
  BUNDLED WITH
428
- 2.5.19
430
+ 2.7.2
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "optparse"
6
+ require 'active_support/core_ext/hash'
7
+
8
+ require_relative "../lib/gitlab_quality/test_tooling"
9
+ params = {}
10
+
11
+ options = OptionParser.new do |opts|
12
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
13
+
14
+ opts.on('-u', '--epic-urls URLS', String, 'Comma-separated list of epic URLs') do |urls|
15
+ params[:epic_urls] = urls.split(',').map(&:strip)
16
+ end
17
+
18
+ opts.on('-f', '--epic-urls-file FILE', String, 'File containing epic URLs (one per line)') do |file|
19
+ params[:epic_urls_file] = file
20
+ end
21
+
22
+ opts.on('-t', '--token TOKEN', String, 'A valid access token with `api` scope and appropriate permissions') do |token|
23
+ params[:token] = token
24
+ end
25
+
26
+ opts.on('-m', '--message MESSAGE', String, 'Custom message template (optional)') do |message|
27
+ params[:message] = message
28
+ end
29
+
30
+ opts.on('--dry-run', "Perform a dry-run (don't post comments)") do
31
+ params[:dry_run] = true
32
+ end
33
+
34
+ opts.on_tail('-v', '--version', 'Show the version') do
35
+ require_relative "../lib/gitlab_quality/test_tooling/version"
36
+ puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
37
+ exit
38
+ end
39
+
40
+ opts.on_tail('-h', '--help', 'Show the usage') do
41
+ puts "Purpose: Send feature readiness assessment notifications to epic authors via @mentions"
42
+ puts opts
43
+ exit
44
+ end
45
+
46
+ opts.parse(ARGV)
47
+ end
48
+
49
+ # Validate required arguments
50
+ raise ArgumentError, "Missing required argument: --token" unless params[:token]
51
+ raise ArgumentError, "Must provide either --epic-urls or --epic-urls-file" unless params[:epic_urls] || params[:epic_urls_file]
52
+
53
+ if params.any?
54
+ GitlabQuality::TestTooling::FeatureReadiness::EpicReadinessNotifier.new(**params).invoke!
55
+ else
56
+ puts options
57
+ exit 1
58
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "json"
5
+ require "logger"
6
+
7
+ module GitlabQuality
8
+ module TestTooling
9
+ module ClickHouse
10
+ class Client
11
+ include HTTParty
12
+
13
+ DEFAULT_BATCH_SIZE = 100_000
14
+ LOG_PREFIX = "[ClickHouse]"
15
+
16
+ def initialize(url:, database:, username: nil, password: nil, logger: ::Logger.new($stdout, level: 1))
17
+ @url = url
18
+ @database = database
19
+ @username = username
20
+ @password = password
21
+ @logger = logger
22
+
23
+ # Set base URI
24
+ self.class.base_uri @url
25
+
26
+ # Set basic auth if credentials provided
27
+ return unless @username && @password
28
+
29
+ self.class.basic_auth @username, @password
30
+ end
31
+
32
+ # Push data to ClickHouse
33
+ #
34
+ # @param table_name [String]
35
+ # @param data [Array<Hash>]
36
+ # @param batch_size [Integer]
37
+ # @return [void]
38
+ def insert_json_data(table_name, data, batch_size: DEFAULT_BATCH_SIZE) # rubocop:disable Metrics/AbcSize
39
+ raise ArgumentError, "Expected data to be an Array, got #{data.class}" unless data.is_a?(Array)
40
+ raise ArgumentError, "Expected all elements of array to be hashes" unless data.is_a?(Array) && data.all?(Hash)
41
+ raise ArgumentError, "Expected data to not be empty" if data.empty?
42
+
43
+ total_batches = (data.size.to_f / batch_size).ceil
44
+ results = data.each_slice(batch_size).with_index.map do |batch, index|
45
+ logger.debug("#{LOG_PREFIX} Pushing batch #{index + 1} of #{total_batches}")
46
+ send_batch(table_name, batch)
47
+ end
48
+ logger.debug("#{LOG_PREFIX} Processed #{results.size} result batches")
49
+ return if results.all? { |res| res[:success] }
50
+
51
+ err = results
52
+ .reject { |res| res[:success] }
53
+ .map { |res| "size: #{res[:count]}, err: #{res[:error]}" }
54
+ .join("\n")
55
+ raise "Failures detected when pushing data to ClickHouse, errors:\n#{err}"
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :logger
61
+
62
+ # Push batch of data
63
+ #
64
+ # @param table_name [String] table name
65
+ # @param batch [Array<Hash>] data batch
66
+ # @return [Hash]
67
+ def send_batch(table_name, batch)
68
+ response = self.class.post('/', {
69
+ body: batch.map(&:to_json).join("\n"),
70
+ headers: { 'Content-Type' => 'application/json' },
71
+ query: {
72
+ database: @database,
73
+ query: "INSERT INTO #{table_name} FORMAT JSONEachRow"
74
+ }
75
+ })
76
+ return { success: true, count: batch.size, response: response.body } if response.code == 200
77
+
78
+ { success: false, count: batch.size, error: response.body }
79
+ rescue StandardError => e
80
+ { success: false, count: batch.size, error: e.message }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ 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