gitlab_quality-test_tooling 2.20.0 → 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.
- 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/lib/gitlab_quality/test_tooling/click_house/client.rb +85 -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/group_labels_client.rb +34 -0
- data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +1 -1
- data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +2 -3
- data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
- data/lib/gitlab_quality/test_tooling/runtime/env.rb +4 -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 +2 -2
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +88 -15
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +71 -34
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +105 -80
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +58 -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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c9587ce85da22ee8e77100c3f46e7d46e9bdfe82f129b8de757af427073b51f2
|
4
|
+
data.tar.gz: 38318dc4c100c8bd8ba2bbbaa66e1551ce99b12214566d667e822d99eee667ca
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bb75f7abc4c94f6e21d58f4af282763dfe5f671e4f94dae194d99f616a276d5958e6c9ffc3af04d92bc4146cbac02194e9444af9205d213a817a035d90b07c8e
|
7
|
+
data.tar.gz: '036382fbbdd1f85e5f01528b8c74214b2eab236519d8b174a7c71b53662896f996a3a0d7a91e21b5afe46494823aa17379a9c2044e769a0413d93626082cdc5c'
|
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
3.
|
1
|
+
3.3.9
|
data/.tool-versions
CHANGED
@@ -1,2 +1,2 @@
|
|
1
|
-
ruby 3.
|
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.20.
|
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 (
|
132
|
-
rubocop (
|
133
|
-
rubocop-
|
134
|
-
rubocop-
|
135
|
-
rubocop-
|
136
|
-
rubocop-
|
137
|
-
rubocop-
|
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
|
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.
|
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.
|
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 (>=
|
321
|
-
|
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, <
|
325
|
-
rubocop-ast (1.
|
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.
|
330
|
-
rubocop (~> 1.
|
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.
|
334
|
+
rubocop-performance (1.21.1)
|
334
335
|
rubocop (>= 1.48.1, < 2.0)
|
335
|
-
rubocop-ast (>= 1.
|
336
|
-
rubocop-rails (2.
|
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.
|
340
|
+
rubocop (>= 1.52.0, < 2.0)
|
340
341
|
rubocop-ast (>= 1.31.1, < 2.0)
|
341
|
-
rubocop-rspec (
|
342
|
-
rubocop (~> 1.
|
343
|
-
|
344
|
-
rubocop
|
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 (~>
|
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.
|
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|
|
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
|