gitlab_quality-test_tooling 3.16.0 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +7 -4
- data/Gemfile.lock +1 -1
- data/README.md +11 -0
- data/exe/relate-failure-issue +10 -0
- data/exe/test-coverage +83 -63
- data/lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter.rb +127 -0
- data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +24 -0
- data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +44 -1
- data/lib/gitlab_quality/test_tooling/version.rb +1 -1
- metadata +3 -7
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/client.rb +0 -50
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +0 -88
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config_helper.rb +0 -115
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +0 -67
- data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +0 -228
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3770b676c5ff745323a47749eb04f305d435cff9769a9b567a1cc76289d5b2bb
|
|
4
|
+
data.tar.gz: 9cab37f5980a19ce80980dec4f97d867ca7bfe6e5524b383c71ea8d01509bbc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42a46bd31d9d7bdf985ad8d430c02e0d7037e116b426ff6358f089ae619f08680cf091758cbe6b5b229612be29e691a5ee760a57fb87b0da91324860b3939590
|
|
7
|
+
data.tar.gz: 71e1eda39f86d10c71d4d14770a925eaad6dc09d019c1cbcf7d769c6e23a73de79cce7fab02412b53ddfee8d4f6e0fa56fa55ae9880636491ffa9aa1024121ae
|
data/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gitlab_quality-test_tooling
|
|
2
2
|
|
|
3
|
-
Ruby gem providing test tooling for GitLab CI —
|
|
3
|
+
Ruby gem providing test tooling for GitLab CI — test quarantine, failure reporting, flaky test tracking, and code coverage analysis.
|
|
4
4
|
|
|
5
5
|
## Tech Stack
|
|
6
6
|
|
|
@@ -70,11 +70,14 @@ Reference: https://docs.gitlab.com/ee/development/changelog.html
|
|
|
70
70
|
|
|
71
71
|
## Key Modules
|
|
72
72
|
|
|
73
|
-
- **
|
|
74
|
-
- **Report** — Creates/updates GitLab issues for test failures, flaky tests, slow tests
|
|
73
|
+
- **Report** — Creates/updates GitLab issues for test failures, flaky tests, slow tests. `RelateFailureIssue` can also trigger a GitLab Duo Agent Platform workflow to investigate newly-created failure issues (see `--duo-analysis` on `exe/relate-failure-issue`); the agent posts its analysis back as an issue comment.
|
|
75
74
|
- **TestQuarantine** — RSpec formatter that skips quarantined tests
|
|
76
75
|
- **CodeCoverage** — Coverage analysis and responsibility patterns
|
|
77
|
-
- **ClickHouse::Client** —
|
|
76
|
+
- **ClickHouse::Client** — HTTP query/insert client for ClickHouse, used by the code coverage subsystem
|
|
77
|
+
|
|
78
|
+
## Review Guidance
|
|
79
|
+
|
|
80
|
+
- `RelateFailureIssue#trigger_duo_analysis` deliberately rescues all `StandardError` (best-effort): a Duo/API failure must never block failure-issue creation. Preserve the broad rescue — don't narrow or remove it.
|
|
78
81
|
|
|
79
82
|
## Release Process
|
|
80
83
|
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -93,6 +93,7 @@ Usage: exe/relate-failure-issue [options]
|
|
|
93
93
|
Labels to exclude when searching for existing issues
|
|
94
94
|
--confidential Makes created new issues confidential
|
|
95
95
|
--dry-run Perform a dry-run (don't create or update issues)
|
|
96
|
+
--duo-analysis MODES Start a GitLab Duo workflow to investigate failure issues (comma-separated modes; supported: create). Off by default
|
|
96
97
|
-v, --version Show the version
|
|
97
98
|
-h, --help Show the usage
|
|
98
99
|
```
|
|
@@ -263,6 +264,12 @@ Usage: exe/feature-readiness-evaluation [options]
|
|
|
263
264
|
|
|
264
265
|
### `exe/test-coverage`
|
|
265
266
|
|
|
267
|
+
Runs in one of three modes:
|
|
268
|
+
|
|
269
|
+
- **Full coverage-metrics export** (default, no `--per-test-coverage` / `--run-aggregation`): reads the LCOV report, Crystalball test map, and test reports, then exports the coverage-metrics and test-file-mapping tables. Requires the full argument set.
|
|
270
|
+
- **Per-test coverage** (`--per-test-coverage GLOB`): inserts per-test rows into `code_coverage.test_coverage_per_file` and runs the daily `test_health_risk` aggregation unless `--skip-aggregation`. Needs only the `--clickhouse-*` options; `--test-reports` and `--jest-quarantine-file` are optional enrichment.
|
|
271
|
+
- **Aggregation only** (`--run-aggregation`): runs the daily `test_health_risk` aggregation and exits. Needs only the `--clickhouse-*` options. Pair with `--skip-aggregation` on batched per-test inserts to aggregate once at the end instead of once per batch.
|
|
272
|
+
|
|
266
273
|
```shell
|
|
267
274
|
Purpose: Export test coverage metrics to ClickHouse
|
|
268
275
|
Usage: exe/test-coverage [options]
|
|
@@ -280,6 +287,10 @@ Options:
|
|
|
280
287
|
ClickHouse shared database name
|
|
281
288
|
--responsibility-patterns PATH
|
|
282
289
|
Path to YAML file with responsibility classification patterns
|
|
290
|
+
--per-test-coverage GLOB Per-test coverage mode (see above). Glob for per-test coverage JSON/NDJSON files.
|
|
291
|
+
--jest-quarantine-file PATH Optional. Path to quarantined_vue3_specs.txt, ingested before the aggregation.
|
|
292
|
+
--skip-aggregation Optional. With --per-test-coverage, insert rows without running the aggregation.
|
|
293
|
+
--run-aggregation Run only the daily test_health_risk aggregation, then exit.
|
|
283
294
|
|
|
284
295
|
Environment variables:
|
|
285
296
|
GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)
|
data/exe/relate-failure-issue
CHANGED
|
@@ -66,6 +66,16 @@ options = OptionParser.new do |opts|
|
|
|
66
66
|
params[:environment_issues_output_file] = file
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
opts.on('--duo-analysis MODES', Array,
|
|
70
|
+
'Start a GitLab Duo Agent Platform workflow to investigate failure issues (the agent posts its ' \
|
|
71
|
+
'analysis back as a comment). Comma-separated modes; currently supported: create. Off by default.') do |modes|
|
|
72
|
+
supported = %w[create]
|
|
73
|
+
invalid = modes - supported
|
|
74
|
+
raise OptionParser::InvalidArgument, "#{invalid.join(',')} (supported: #{supported.join(',')})" if invalid.any?
|
|
75
|
+
|
|
76
|
+
params[:duo_analysis] = modes
|
|
77
|
+
end
|
|
78
|
+
|
|
69
79
|
opts.on_tail('-v', '--version', 'Show the version') do
|
|
70
80
|
require_relative "../lib/gitlab_quality/test_tooling/version"
|
|
71
81
|
puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
|
data/exe/test-coverage
CHANGED
|
@@ -21,12 +21,12 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
|
|
|
21
21
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
|
|
22
22
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
|
|
23
23
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data'
|
|
24
|
+
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter'
|
|
24
25
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
|
|
25
26
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier'
|
|
26
27
|
require_relative '../lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config'
|
|
27
28
|
|
|
28
29
|
params = {}
|
|
29
|
-
required_params = [:test_reports, :coverage_report, :test_map, :clickhouse_url, :clickhouse_database, :clickhouse_username, :clickhouse_shared_database, :responsibility_patterns]
|
|
30
30
|
|
|
31
31
|
options = OptionParser.new do |opts|
|
|
32
32
|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
|
@@ -68,19 +68,35 @@ options = OptionParser.new do |opts|
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
opts.on('--per-test-coverage GLOB',
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'
|
|
71
|
+
'Per-test coverage mode. Glob for per-test coverage JSON/NDJSON files. ' \
|
|
72
|
+
'Inserts rows into code_coverage.test_coverage_per_file and runs the daily ' \
|
|
73
|
+
'test_health_risk aggregation unless --skip-aggregation. Needs only the ' \
|
|
74
|
+
'--clickhouse-* options; --test-reports and --jest-quarantine-file are ' \
|
|
75
|
+
'optional enrichment. (e.g., "tmp/per-test-coverage-*.ndjson")') do |pattern|
|
|
74
76
|
params[:per_test_coverage] = pattern
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
opts.on('--jest-quarantine-file PATH',
|
|
78
80
|
'Optional. Path to quarantined_vue3_specs.txt. When provided alongside ' \
|
|
79
81
|
'--per-test-coverage, populates code_coverage.jest_quarantined_tests_today ' \
|
|
80
|
-
'before the
|
|
82
|
+
'before the aggregation so Jest tests count toward is_quarantined.') do |path|
|
|
81
83
|
params[:jest_quarantine_file] = path
|
|
82
84
|
end
|
|
83
85
|
|
|
86
|
+
opts.on('--skip-aggregation',
|
|
87
|
+
'Optional. With --per-test-coverage, insert rows (and ingest the jest ' \
|
|
88
|
+
'quarantine file) without running the test_health_risk aggregation. Use ' \
|
|
89
|
+
'when streaming many batches, then run --run-aggregation once at the end.') do
|
|
90
|
+
params[:skip_aggregation] = true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
opts.on('--run-aggregation',
|
|
94
|
+
'Run only the daily test_health_risk aggregation over ' \
|
|
95
|
+
'code_coverage.test_coverage_per_file, then exit. Needs only the ' \
|
|
96
|
+
'--clickhouse-* options.') do
|
|
97
|
+
params[:run_aggregation] = true
|
|
98
|
+
end
|
|
99
|
+
|
|
84
100
|
opts.separator ""
|
|
85
101
|
opts.separator "Environment variables:"
|
|
86
102
|
opts.separator " GLCI_CLICKHOUSE_METRICS_PASSWORD ClickHouse password (required, not passed via CLI for security)"
|
|
@@ -102,31 +118,74 @@ options = OptionParser.new do |opts|
|
|
|
102
118
|
opts.parse(ARGV)
|
|
103
119
|
end
|
|
104
120
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
mode =
|
|
122
|
+
if params[:run_aggregation]
|
|
123
|
+
puts "Warning: --per-test-coverage is ignored in --run-aggregation mode." if params[:per_test_coverage]
|
|
124
|
+
:aggregation
|
|
125
|
+
elsif params[:per_test_coverage]
|
|
126
|
+
:per_test
|
|
127
|
+
else
|
|
128
|
+
:full_export
|
|
110
129
|
end
|
|
111
130
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
# Per-test and aggregation-only modes are self-contained: they touch only the
|
|
132
|
+
# ClickHouse connection, not the LCOV report, Crystalball test map, or
|
|
133
|
+
# responsibility patterns the full coverage-metrics export needs.
|
|
134
|
+
clickhouse_required = [:clickhouse_url, :clickhouse_database, :clickhouse_username]
|
|
135
|
+
full_export_required =
|
|
136
|
+
[:test_reports, :coverage_report, :test_map, :responsibility_patterns, :clickhouse_shared_database] + clickhouse_required
|
|
137
|
+
required_params = mode == :full_export ? full_export_required : clickhouse_required
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
unless params.any? && (required_params - params.keys).none?
|
|
140
|
+
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
141
|
+
puts options
|
|
142
|
+
exit 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
clickhouse_password = ENV.fetch('GLCI_CLICKHOUSE_METRICS_PASSWORD', nil)
|
|
146
|
+
if clickhouse_password.to_s.strip.empty?
|
|
147
|
+
puts "Error: GLCI_CLICKHOUSE_METRICS_PASSWORD environment variable must be set and not empty"
|
|
148
|
+
exit 1
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
non_empty_params = mode == :full_export ? clickhouse_required + [:clickhouse_shared_database] : clickhouse_required
|
|
152
|
+
non_empty_params.each do |param|
|
|
153
|
+
next unless params[param].to_s.strip.empty?
|
|
154
|
+
|
|
155
|
+
puts "Error: --#{param.to_s.tr('_', '-')} cannot be empty"
|
|
156
|
+
exit 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
uri = URI.parse(params[:clickhouse_url])
|
|
161
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
162
|
+
puts "Error: --clickhouse-url must be a valid HTTP or HTTPS URL"
|
|
127
163
|
exit 1
|
|
128
164
|
end
|
|
165
|
+
rescue URI::InvalidURIError
|
|
166
|
+
puts "Error: --clickhouse-url is not a valid URL format"
|
|
167
|
+
exit 1
|
|
168
|
+
end
|
|
129
169
|
|
|
170
|
+
clickhouse_data = {
|
|
171
|
+
url: params[:clickhouse_url],
|
|
172
|
+
database: params[:clickhouse_database],
|
|
173
|
+
username: params[:clickhouse_username],
|
|
174
|
+
password: clickhouse_password
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case mode
|
|
178
|
+
when :aggregation
|
|
179
|
+
GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthRiskAggregator.new(**clickhouse_data).run
|
|
180
|
+
when :per_test
|
|
181
|
+
GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageExporter.new(
|
|
182
|
+
coverage_glob: params[:per_test_coverage],
|
|
183
|
+
clickhouse: clickhouse_data,
|
|
184
|
+
test_reports: params[:test_reports],
|
|
185
|
+
jest_quarantine_file: params[:jest_quarantine_file],
|
|
186
|
+
skip_aggregation: params.fetch(:skip_aggregation, false)
|
|
187
|
+
).run
|
|
188
|
+
when :full_export
|
|
130
189
|
artifacts = GitlabQuality::TestTooling::CodeCoverage::Artifacts.new(
|
|
131
190
|
coverage_report: params[:coverage_report],
|
|
132
191
|
test_map: params[:test_map],
|
|
@@ -181,13 +240,6 @@ if params.any? && (required_params - params.keys).none?
|
|
|
181
240
|
test_classifications
|
|
182
241
|
)
|
|
183
242
|
|
|
184
|
-
clickhouse_data = {
|
|
185
|
-
url: params[:clickhouse_url],
|
|
186
|
-
database: params[:clickhouse_database],
|
|
187
|
-
username: params[:clickhouse_username],
|
|
188
|
-
password: clickhouse_password
|
|
189
|
-
}
|
|
190
|
-
|
|
191
243
|
shared_clickhouse_data = {
|
|
192
244
|
url: params[:clickhouse_url],
|
|
193
245
|
database: params[:clickhouse_shared_database],
|
|
@@ -210,36 +262,4 @@ if params.any? && (required_params - params.keys).none?
|
|
|
210
262
|
)
|
|
211
263
|
test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**shared_clickhouse_data)
|
|
212
264
|
test_file_mappings_table.push(test_file_mapping_data.as_db_table)
|
|
213
|
-
|
|
214
|
-
# Per-test coverage export (optional). Only runs when --per-test-coverage
|
|
215
|
-
# was provided AND at least one matching artifact exists.
|
|
216
|
-
if params[:per_test_coverage]
|
|
217
|
-
per_test_files = Dir.glob(params[:per_test_coverage])
|
|
218
|
-
if per_test_files.any?
|
|
219
|
-
per_test_data = GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageData.new(
|
|
220
|
-
per_test_files,
|
|
221
|
-
tests_to_categories: tests_to_categories,
|
|
222
|
-
feature_categories_to_teams: category_owners.feature_categories_to_teams,
|
|
223
|
-
captured_sha: ENV.fetch('CI_COMMIT_SHA', '')
|
|
224
|
-
)
|
|
225
|
-
per_test_coverage_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::PerTestCoverageTable.new(**clickhouse_data)
|
|
226
|
-
per_test_coverage_table.push(per_test_data.as_db_table)
|
|
227
|
-
|
|
228
|
-
if params[:jest_quarantine_file] && File.exist?(params[:jest_quarantine_file])
|
|
229
|
-
jest_qt = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::JestQuarantinedTestsTable.new(**clickhouse_data)
|
|
230
|
-
jest_qt.populate(quarantine_file: params[:jest_quarantine_file])
|
|
231
|
-
elsif params[:jest_quarantine_file]
|
|
232
|
-
puts "Jest quarantine file not found at #{params[:jest_quarantine_file]}; skipping jest quarantine ingestion."
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
aggregator = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestHealthRiskAggregator.new(**clickhouse_data)
|
|
236
|
-
aggregator.run
|
|
237
|
-
else
|
|
238
|
-
puts "No per-test coverage artifacts matched #{params[:per_test_coverage]}; skipping per-test export and aggregation."
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
else
|
|
242
|
-
puts "Missing argument(s). Required arguments are: #{required_params}\nPassed arguments are: #{params}\n"
|
|
243
|
-
puts options
|
|
244
|
-
exit 1
|
|
245
265
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
6
|
+
module GitlabQuality
|
|
7
|
+
module TestTooling
|
|
8
|
+
module CodeCoverage
|
|
9
|
+
# Standalone per-test coverage export: inserts per-test, per-source-file
|
|
10
|
+
# rows into `code_coverage.test_coverage_per_file` and, unless skipped,
|
|
11
|
+
# runs the daily `test_health_risk` aggregation.
|
|
12
|
+
#
|
|
13
|
+
# Unlike the full coverage-metrics export, this path needs only ClickHouse
|
|
14
|
+
# credentials and the per-test coverage glob, not the LCOV report, the
|
|
15
|
+
# Crystalball test map, or the responsibility patterns. Category enrichment
|
|
16
|
+
# (feature_category/group/stage/section per row) is optional: pass
|
|
17
|
+
# `test_reports` to map test files to feature categories via the test
|
|
18
|
+
# report JSON, otherwise those columns stay blank.
|
|
19
|
+
#
|
|
20
|
+
# `skip_aggregation` lets a batched caller insert without re-running the
|
|
21
|
+
# table-wide aggregation on every batch. The streaming export invokes this
|
|
22
|
+
# once per artifact batch with `skip_aggregation: true`, then runs the
|
|
23
|
+
# aggregation once at the end.
|
|
24
|
+
class PerTestCoverageExporter
|
|
25
|
+
# @param clickhouse [Hash] connection params (:url, :database, :username, :password)
|
|
26
|
+
def initialize(
|
|
27
|
+
coverage_glob:, clickhouse:,
|
|
28
|
+
test_reports: nil, jest_quarantine_file: nil,
|
|
29
|
+
captured_sha: ENV.fetch('CI_COMMIT_SHA', ''), skip_aggregation: false, logger: nil)
|
|
30
|
+
@coverage_glob = coverage_glob
|
|
31
|
+
@clickhouse = clickhouse
|
|
32
|
+
@test_reports = test_reports
|
|
33
|
+
@jest_quarantine_file = jest_quarantine_file
|
|
34
|
+
@captured_sha = captured_sha.to_s
|
|
35
|
+
@skip_aggregation = skip_aggregation
|
|
36
|
+
@logger = logger || ::Logger.new($stdout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [void]
|
|
40
|
+
def run
|
|
41
|
+
coverage_files = Dir.glob(coverage_glob)
|
|
42
|
+
if coverage_files.empty?
|
|
43
|
+
logger.info(
|
|
44
|
+
"#{ClickHouse::LOG_PREFIX} No per-test coverage artifacts matched #{coverage_glob}; nothing to export."
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
insert(coverage_files)
|
|
50
|
+
ingest_jest_quarantine
|
|
51
|
+
aggregate unless skip_aggregation
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :coverage_glob, :clickhouse, :test_reports, :jest_quarantine_file,
|
|
57
|
+
:captured_sha, :skip_aggregation, :logger
|
|
58
|
+
|
|
59
|
+
def insert(coverage_files)
|
|
60
|
+
tests_to_categories, feature_categories_to_teams = resolve_categories
|
|
61
|
+
|
|
62
|
+
data = PerTestCoverageData.new(
|
|
63
|
+
coverage_files,
|
|
64
|
+
tests_to_categories: tests_to_categories,
|
|
65
|
+
feature_categories_to_teams: feature_categories_to_teams,
|
|
66
|
+
captured_sha: captured_sha
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
ClickHouse::PerTestCoverageTable.new(**clickhouse_credentials).push(data.as_db_table)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ingest_jest_quarantine
|
|
73
|
+
return unless jest_quarantine_file
|
|
74
|
+
|
|
75
|
+
unless File.exist?(jest_quarantine_file)
|
|
76
|
+
logger.info(
|
|
77
|
+
"#{ClickHouse::LOG_PREFIX} Jest quarantine file not found at #{jest_quarantine_file}; " \
|
|
78
|
+
"skipping jest quarantine ingestion."
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
ClickHouse::JestQuarantinedTestsTable.new(**clickhouse_credentials)
|
|
84
|
+
.populate(quarantine_file: jest_quarantine_file)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def aggregate
|
|
88
|
+
ClickHouse::TestHealthRiskAggregator.new(**clickhouse_credentials).run
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `tests_to_categories` comes from the test report JSON; without it, rows
|
|
92
|
+
# carry blank categories. `CategoryOwners` fetches stages.yml over HTTP,
|
|
93
|
+
# so only build it when there are reports to enrich.
|
|
94
|
+
def resolve_categories
|
|
95
|
+
return [{}, {}] unless test_reports
|
|
96
|
+
|
|
97
|
+
# Comma-separated patterns, mirroring Artifacts#test_reports.
|
|
98
|
+
patterns = test_reports.split(',').map(&:strip).reject(&:empty?)
|
|
99
|
+
report_files = Dir.glob(patterns)
|
|
100
|
+
return [{}, {}] if report_files.empty?
|
|
101
|
+
|
|
102
|
+
[tests_to_categories_from(report_files), CategoryOwners.new.feature_categories_to_teams]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def tests_to_categories_from(report_files)
|
|
106
|
+
report_files.each_with_object({}) do |file, combined|
|
|
107
|
+
# Category enrichment is optional, so a malformed or vanished report
|
|
108
|
+
# shouldn't abort the per-test insert (the rows are the point). Warn
|
|
109
|
+
# and skip the file, leaving those tests with blank categories.
|
|
110
|
+
begin
|
|
111
|
+
report = TestReport.new(JSON.parse(File.read(file)))
|
|
112
|
+
rescue JSON::ParserError, Errno::ENOENT => e
|
|
113
|
+
logger.warn("#{ClickHouse::LOG_PREFIX} Skipping unreadable test report #{file}: #{e.message}")
|
|
114
|
+
next
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
combined.merge!(report.tests_to_categories) { |_, old_val, new_val| (old_val + new_val).uniq }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def clickhouse_credentials
|
|
122
|
+
clickhouse.merge(logger: logger)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -43,6 +43,8 @@ module GitlabQuality
|
|
|
43
43
|
# The GitLab client is used for API access: https://github.com/NARKOZ/gitlab
|
|
44
44
|
class IssuesClient < GitlabClient
|
|
45
45
|
REPORTER_ACCESS_LEVEL = 20
|
|
46
|
+
DUO_WORKFLOW_TIMEOUT = 30
|
|
47
|
+
DUO_WORKFLOW_DEFINITION = 'developer/v1'
|
|
46
48
|
|
|
47
49
|
def assert_user_permission!
|
|
48
50
|
handle_gitlab_client_exceptions do
|
|
@@ -123,6 +125,28 @@ module GitlabQuality
|
|
|
123
125
|
end
|
|
124
126
|
end
|
|
125
127
|
|
|
128
|
+
# Starts a GitLab Duo Agent Platform workflow (Flows API).
|
|
129
|
+
# The agent investigates and posts its own analysis back, so no follow-up call is needed.
|
|
130
|
+
#
|
|
131
|
+
# This is a best-effort helper: it deliberately does NOT use handle_gitlab_client_exceptions
|
|
132
|
+
# (which would retry with long backoffs and post failures to Slack) and uses a short timeout
|
|
133
|
+
# so a slow or unavailable Duo endpoint can never stall the reporting job. The caller is
|
|
134
|
+
# expected to rescue any error.
|
|
135
|
+
#
|
|
136
|
+
# project_id accepts either a numeric ID or a namespace path (e.g. 'gitlab-org/quality/...'),
|
|
137
|
+
# matching the GitLab API's project-identifier convention; the path form is verified working.
|
|
138
|
+
def start_duo_workflow(goal:, workflow_definition: DUO_WORKFLOW_DEFINITION, project_id: project)
|
|
139
|
+
client.post('/ai/duo_workflows/workflows',
|
|
140
|
+
timeout: DUO_WORKFLOW_TIMEOUT,
|
|
141
|
+
headers: { 'Content-Type' => 'application/json' },
|
|
142
|
+
body: {
|
|
143
|
+
project_id: project_id,
|
|
144
|
+
goal: goal,
|
|
145
|
+
workflow_definition: workflow_definition,
|
|
146
|
+
start_workflow: true
|
|
147
|
+
}.to_json)
|
|
148
|
+
end
|
|
149
|
+
|
|
126
150
|
def edit_issue_note(issue_iid:, note_id:, note:)
|
|
127
151
|
handle_gitlab_client_exceptions do
|
|
128
152
|
client.edit_issue_note(project, issue_iid, note_id, note)
|
|
@@ -83,12 +83,13 @@ module GitlabQuality
|
|
|
83
83
|
@metrics_files = Array(metrics_files)
|
|
84
84
|
@group_similar = group_similar
|
|
85
85
|
@environment_issues_output_file = environment_issues_output_file
|
|
86
|
+
@duo_analysis_modes = Array(kwargs[:duo_analysis])
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
private
|
|
89
90
|
|
|
90
91
|
attr_reader :max_diff_ratio, :system_logs, :base_issue_labels, :exclude_labels_for_search, :metrics_files, :ops_gitlab_client, :group_similar,
|
|
91
|
-
:environment_issues_output_file
|
|
92
|
+
:environment_issues_output_file, :duo_analysis_modes
|
|
92
93
|
|
|
93
94
|
def run!
|
|
94
95
|
puts "Reporting test failures in `#{files.join(',')}` as issues in project `#{project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
|
|
@@ -294,9 +295,47 @@ module GitlabQuality
|
|
|
294
295
|
# On a dry run, created_issue may not be populated
|
|
295
296
|
test.failure_issue ||= created_issue&.web_url
|
|
296
297
|
|
|
298
|
+
trigger_duo_analysis(created_issue, test) if duo_analysis_modes.include?('create')
|
|
299
|
+
|
|
297
300
|
created_issue
|
|
298
301
|
end
|
|
299
302
|
|
|
303
|
+
# Best-effort: start a GitLab Duo workflow to investigate the issue; the agent comments its
|
|
304
|
+
# analysis back. A failure here must never block issue creation or the pipeline.
|
|
305
|
+
def trigger_duo_analysis(issue, test)
|
|
306
|
+
return unless issue&.web_url
|
|
307
|
+
|
|
308
|
+
gitlab.start_duo_workflow(goal: duo_analysis_goal(issue, test))
|
|
309
|
+
puts " => Triggered Duo failure analysis workflow for #{issue.web_url}"
|
|
310
|
+
rescue StandardError => e
|
|
311
|
+
warn(" => Could not trigger Duo failure analysis for #{issue.web_url} (ignored): #{e.class}: #{e.message}")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def duo_analysis_goal(issue, test)
|
|
315
|
+
<<~GOAL.chomp
|
|
316
|
+
Investigate the newly-reported end-to-end test failure described in this issue: #{issue.web_url}
|
|
317
|
+
(failing spec: `#{test.name}`). Read the issue for the failing spec and stack trace.
|
|
318
|
+
|
|
319
|
+
Investigate the `gitlab-org/gitlab` project (where the product code and E2E specs live —
|
|
320
|
+
note this is NOT the project hosting this issue): inspect its recent commits and merge
|
|
321
|
+
requests on the default branch using GitLab tools, and consult the Orbit knowledge graph
|
|
322
|
+
via MCP tools if it is available. Using those, identify the most likely cause of this
|
|
323
|
+
failure. Then post a comment on that issue with your analysis.
|
|
324
|
+
|
|
325
|
+
The failure may be caused by a product code change, or it may be environmental — for
|
|
326
|
+
example a flaky test, an infrastructure/environment issue, or a CI runner problem. Decide
|
|
327
|
+
which is more likely:
|
|
328
|
+
|
|
329
|
+
- If a specific change or merge request looks responsible, name the likely author in your
|
|
330
|
+
comment and ask them to confirm. Frame this as advisory ("this looks likely related —
|
|
331
|
+
please confirm"), never as an assertion of blame.
|
|
332
|
+
- If the failure looks environmental, flaky, or otherwise not attributable to a specific
|
|
333
|
+
change, explain why — but do not ping or attribute it to anyone.
|
|
334
|
+
|
|
335
|
+
This is an automated, best-effort request to speed up triage of newly-detected failures.
|
|
336
|
+
GOAL
|
|
337
|
+
end
|
|
338
|
+
|
|
300
339
|
def pipeline_issues_with_similar_stacktrace(test)
|
|
301
340
|
search_labels = (base_issue_labels + Set.new(%w[test failure::new])).to_a
|
|
302
341
|
not_labels = exclude_labels_for_search.to_a
|
|
@@ -428,6 +467,10 @@ module GitlabQuality
|
|
|
428
467
|
end
|
|
429
468
|
|
|
430
469
|
def generate_diff_link
|
|
470
|
+
# Outside CI (e.g. local runs) there is no pipeline to diff against, so skip gracefully
|
|
471
|
+
# rather than crashing on a nil pipeline URL or an unsupported project.
|
|
472
|
+
return "No commit diff available (no CI pipeline context)." unless Runtime::Env.ci_pipeline_url
|
|
473
|
+
|
|
431
474
|
initialize_gitlab_ops_client
|
|
432
475
|
|
|
433
476
|
if Runtime::Env.ci_pipeline_url.include?('ops.gitlab.net')
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab_quality-test_tooling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab Quality
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: climate_control
|
|
@@ -502,6 +502,7 @@ files:
|
|
|
502
502
|
- lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb
|
|
503
503
|
- lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb
|
|
504
504
|
- lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb
|
|
505
|
+
- lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter.rb
|
|
505
506
|
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_classifier.rb
|
|
506
507
|
- lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
|
|
507
508
|
- lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
|
|
@@ -594,11 +595,6 @@ files:
|
|
|
594
595
|
- lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb
|
|
595
596
|
- lib/gitlab_quality/test_tooling/test_metric/json_test_metric.rb
|
|
596
597
|
- lib/gitlab_quality/test_tooling/test_metrics/json_test_metric_collection.rb
|
|
597
|
-
- lib/gitlab_quality/test_tooling/test_metrics_exporter/client.rb
|
|
598
|
-
- lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb
|
|
599
|
-
- lib/gitlab_quality/test_tooling/test_metrics_exporter/config_helper.rb
|
|
600
|
-
- lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb
|
|
601
|
-
- lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb
|
|
602
598
|
- lib/gitlab_quality/test_tooling/test_quarantine/quarantine_formatter.rb
|
|
603
599
|
- lib/gitlab_quality/test_tooling/test_quarantine/quarantine_helper.rb
|
|
604
600
|
- lib/gitlab_quality/test_tooling/test_result/base_test_result.rb
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "httparty"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module GitlabQuality
|
|
7
|
-
module TestTooling
|
|
8
|
-
module TestMetricsExporter
|
|
9
|
-
class Client
|
|
10
|
-
ResponseError = Class.new(StandardError)
|
|
11
|
-
|
|
12
|
-
TESTS_PATH = "/api/v1/tests"
|
|
13
|
-
# Observer enforces a per-request cap; batch above this size to avoid silent failures.
|
|
14
|
-
MAX_BATCH_SIZE = 10_000
|
|
15
|
-
|
|
16
|
-
def initialize(url:, token:)
|
|
17
|
-
@url = url
|
|
18
|
-
@token = token
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# POST array of test metric records to the observer service.
|
|
22
|
-
# Wraps each batch as { "tests" => [...] } and splits oversized payloads
|
|
23
|
-
# into chunks of at most MAX_BATCH_SIZE records.
|
|
24
|
-
#
|
|
25
|
-
# @param tests [Array<Hash>]
|
|
26
|
-
# @return [Boolean] true when every batch succeeds (or input is empty)
|
|
27
|
-
# @raise [ResponseError] on the first non-2xx batch response
|
|
28
|
-
def post_tests(tests)
|
|
29
|
-
tests.each_slice(MAX_BATCH_SIZE) do |batch|
|
|
30
|
-
response = HTTParty.post(
|
|
31
|
-
"#{url.to_s.chomp('/')}#{TESTS_PATH}",
|
|
32
|
-
body: { tests: batch }.to_json,
|
|
33
|
-
headers: {
|
|
34
|
-
"X-Gitlab-Token" => token,
|
|
35
|
-
"Content-Type" => "application/json"
|
|
36
|
-
}
|
|
37
|
-
)
|
|
38
|
-
raise ResponseError, "Observer request failed with status #{response.code}: #{response.body}" unless response.success?
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
true
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
attr_reader :url, :token
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "logger"
|
|
4
|
-
require "singleton"
|
|
5
|
-
|
|
6
|
-
module GitlabQuality
|
|
7
|
-
module TestTooling
|
|
8
|
-
module TestMetricsExporter
|
|
9
|
-
class Config
|
|
10
|
-
include Singleton
|
|
11
|
-
|
|
12
|
-
class << self
|
|
13
|
-
def configuration
|
|
14
|
-
Config.instance
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def configure
|
|
18
|
-
yield(configuration)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
attr_accessor :run_type,
|
|
23
|
-
:observer_url,
|
|
24
|
-
:observer_token
|
|
25
|
-
attr_writer :extra_rspec_metadata_keys,
|
|
26
|
-
:skip_record_proc,
|
|
27
|
-
:test_retried_proc,
|
|
28
|
-
:custom_metrics_proc,
|
|
29
|
-
:spec_file_path_prefix,
|
|
30
|
-
:logger
|
|
31
|
-
|
|
32
|
-
# Whether observer export is configured
|
|
33
|
-
#
|
|
34
|
-
# Export is considered enabled when all required attributes are set
|
|
35
|
-
#
|
|
36
|
-
# @return [Boolean]
|
|
37
|
-
def observer_configured?
|
|
38
|
-
[observer_url, observer_token].none? { |value| value.nil? || value.to_s.empty? }
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Extra rspec metadata keys to include in exported metrics
|
|
42
|
-
#
|
|
43
|
-
# @return [Array<Symbol>]
|
|
44
|
-
def extra_rspec_metadata_keys
|
|
45
|
-
@extra_rspec_metadata_keys ||= []
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Extra path prefix for constructing full file path within mono-repository setups
|
|
49
|
-
#
|
|
50
|
-
# @return [String]
|
|
51
|
-
def spec_file_path_prefix
|
|
52
|
-
@spec_file_path_prefix ||= ""
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# A lambda that determines whether to skip recording a test result
|
|
56
|
-
#
|
|
57
|
-
# This is useful when you would want to skip initial failure when retrying specs is set up in a separate process
|
|
58
|
-
# and you want to avoid duplicate records
|
|
59
|
-
#
|
|
60
|
-
# @return [Proc]
|
|
61
|
-
def skip_record_proc
|
|
62
|
-
@skip_record_proc ||= ->(_example) { false }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# A lambda that determines whether a test was retried or not
|
|
66
|
-
#
|
|
67
|
-
# @return [Proc]
|
|
68
|
-
def test_retried_proc
|
|
69
|
-
@test_retried_proc ||= ->(_example) { false }
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# A lambda that return hash with additional custom metrics
|
|
73
|
-
#
|
|
74
|
-
# @return [Proc]
|
|
75
|
-
def custom_metrics_proc
|
|
76
|
-
@custom_metrics_proc ||= ->(_example) { {} }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Logger instance
|
|
80
|
-
#
|
|
81
|
-
# @return [Logger]
|
|
82
|
-
def logger
|
|
83
|
-
@logger ||= Logger.new($stdout, level: Logger::INFO)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'logger'
|
|
4
|
-
require 'active_support/core_ext/object/blank'
|
|
5
|
-
|
|
6
|
-
require_relative 'config'
|
|
7
|
-
require_relative 'formatter'
|
|
8
|
-
|
|
9
|
-
module GitlabQuality
|
|
10
|
-
module TestTooling
|
|
11
|
-
module TestMetricsExporter
|
|
12
|
-
class ConfigHelper
|
|
13
|
-
REQUIRED_OBSERVER_ENV_VARS = %w[
|
|
14
|
-
GLCI_OBSERVER_URL
|
|
15
|
-
GLCI_OBSERVER_AUTH_TOKEN
|
|
16
|
-
].freeze
|
|
17
|
-
|
|
18
|
-
class << self
|
|
19
|
-
def configure!(run_type = test_run_type)
|
|
20
|
-
return unless ENV.fetch("CI", nil) && ENV.fetch("GLCI_EXPORT_TEST_METRICS", "true") == "true" && run_type
|
|
21
|
-
|
|
22
|
-
RSpec.configure do |rspec_config|
|
|
23
|
-
next if rspec_config.dry_run?
|
|
24
|
-
|
|
25
|
-
Config.configure do |exporter_config|
|
|
26
|
-
self.logger = exporter_config.logger
|
|
27
|
-
next warn_missing_observer_variables unless observer_env_vars_present?
|
|
28
|
-
|
|
29
|
-
yield(exporter_config) if block_given?
|
|
30
|
-
configure_exporter!(exporter_config, run_type)
|
|
31
|
-
|
|
32
|
-
rspec_config.add_formatter Formatter
|
|
33
|
-
|
|
34
|
-
logger.info("Test metrics export is enabled for run type: #{run_type}")
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
attr_writer :logger
|
|
42
|
-
|
|
43
|
-
def logger
|
|
44
|
-
@logger ||= Logger.new($stdout)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def observer_env_vars_present?
|
|
48
|
-
REQUIRED_OBSERVER_ENV_VARS.all? { |var| ENV.fetch(var, nil) && !ENV[var].empty? }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def configure_exporter!(config, run_type)
|
|
52
|
-
config.run_type = run_type
|
|
53
|
-
config.custom_metrics_proc = custom_metrics_proc
|
|
54
|
-
|
|
55
|
-
configure_observer!(config)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def configure_observer!(config)
|
|
59
|
-
config.observer_url = observer_url
|
|
60
|
-
config.observer_token = observer_token
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def warn_missing_observer_variables
|
|
64
|
-
missing = REQUIRED_OBSERVER_ENV_VARS.reject { |var| ENV.fetch(var, nil) && !ENV[var].empty? }
|
|
65
|
-
logger.warn("Test metrics export is enabled but missing environment variables: #{missing.join(', ')}")
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def custom_metrics_proc
|
|
69
|
-
proc do |_example|
|
|
70
|
-
{ pipeline_type: pipeline_type, ci_pipeline_id: ci_pipeline_id }
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def default_branch?
|
|
75
|
-
ENV["CI_COMMIT_REF_NAME"] == ENV["CI_DEFAULT_BRANCH"]
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def pipeline_type
|
|
79
|
-
@pipeline_type ||= if default_branch? && ENV["SCHEDULE_TYPE"].present?
|
|
80
|
-
"default_branch_scheduled_pipeline"
|
|
81
|
-
elsif default_branch?
|
|
82
|
-
"default_branch_pipeline"
|
|
83
|
-
elsif ENV["CI_COMMIT_REF_NAME"]&.match?(/^[\d-]+-stable-ee$/)
|
|
84
|
-
"stable_branch_pipeline"
|
|
85
|
-
elsif ENV["CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]&.match?(/^[\d-]+-stable-ee$/)
|
|
86
|
-
"backport_merge_request_pipeline"
|
|
87
|
-
elsif ENV["CI_MERGE_REQUEST_IID"].present?
|
|
88
|
-
"merge_request_pipeline"
|
|
89
|
-
elsif ENV["CI_PIPELINE_SOURCE"] == "pipeline"
|
|
90
|
-
"downstream_pipeline"
|
|
91
|
-
else
|
|
92
|
-
"unknown"
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def test_run_type
|
|
97
|
-
@run_type ||= ENV.fetch("GLCI_TEST_METRICS_RUN_TYPE", nil)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def observer_url
|
|
101
|
-
ENV.fetch("GLCI_OBSERVER_URL", nil)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def observer_token
|
|
105
|
-
ENV.fetch("GLCI_OBSERVER_AUTH_TOKEN", nil)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def ci_pipeline_id
|
|
109
|
-
(ENV["PARENT_PIPELINE_ID"] || ENV.fetch("CI_PIPELINE_ID", nil)).to_i
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rspec/core/formatters/base_formatter"
|
|
4
|
-
|
|
5
|
-
require_relative "test_metrics"
|
|
6
|
-
require_relative "client"
|
|
7
|
-
|
|
8
|
-
module GitlabQuality
|
|
9
|
-
module TestTooling
|
|
10
|
-
module TestMetricsExporter
|
|
11
|
-
class Formatter < RSpec::Core::Formatters::BaseFormatter
|
|
12
|
-
RSpec::Core::Formatters.register(self, :stop)
|
|
13
|
-
|
|
14
|
-
LOG_PREFIX = "[MetricsExporter]"
|
|
15
|
-
|
|
16
|
-
def stop(notification)
|
|
17
|
-
logger.debug("#{LOG_PREFIX} Starting test metrics export")
|
|
18
|
-
data = notification.examples.filter_map do |example|
|
|
19
|
-
next if config.skip_record_proc.call(example)
|
|
20
|
-
|
|
21
|
-
TestMetrics.new(example, time).data
|
|
22
|
-
end
|
|
23
|
-
return logger.warn("#{LOG_PREFIX} No test execution records found, metrics will not be exported!") if data.empty?
|
|
24
|
-
|
|
25
|
-
push_to_observer(data)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def config
|
|
31
|
-
Config.configuration
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def logger
|
|
35
|
-
config.logger
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Single common timestamp for all exported example metrics to keep data points consistently grouped
|
|
39
|
-
#
|
|
40
|
-
# @return [String]
|
|
41
|
-
def time
|
|
42
|
-
return @time if @time
|
|
43
|
-
|
|
44
|
-
ci_created_at = ENV.fetch("CI_PIPELINE_CREATED_AT", nil)
|
|
45
|
-
@time = (ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc).strftime('%Y-%m-%dT%H:%M:%S.%6N')
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Push data to observer service
|
|
49
|
-
#
|
|
50
|
-
# @param data [Array<Hash>]
|
|
51
|
-
# @return [void]
|
|
52
|
-
def push_to_observer(data)
|
|
53
|
-
return logger.debug("#{LOG_PREFIX} Observer configuration missing, skipping export!") unless config.observer_configured?
|
|
54
|
-
|
|
55
|
-
observer_client.post_tests(data)
|
|
56
|
-
logger.info("#{LOG_PREFIX} Successfully pushed #{data.size} entries to Observer!")
|
|
57
|
-
rescue StandardError => e
|
|
58
|
-
logger.error("#{LOG_PREFIX} Error occurred while pushing metrics to Observer: #{e.message}")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def observer_client
|
|
62
|
-
Client.new(url: config.observer_url, token: config.observer_token)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'time'
|
|
4
|
-
|
|
5
|
-
module GitlabQuality
|
|
6
|
-
module TestTooling
|
|
7
|
-
module TestMetricsExporter
|
|
8
|
-
class TestMetrics
|
|
9
|
-
def initialize(example, timestamp)
|
|
10
|
-
@example = example
|
|
11
|
-
@timestamp = timestamp
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Test data hash
|
|
15
|
-
#
|
|
16
|
-
# @return [Hash]
|
|
17
|
-
def data
|
|
18
|
-
{
|
|
19
|
-
timestamp: timestamp,
|
|
20
|
-
**rspec_metrics,
|
|
21
|
-
**ci_metrics,
|
|
22
|
-
**custom_metrics
|
|
23
|
-
}.compact
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
attr_reader :example, :timestamp
|
|
29
|
-
|
|
30
|
-
# Exporter configuration
|
|
31
|
-
#
|
|
32
|
-
# @return [Config]
|
|
33
|
-
def config
|
|
34
|
-
Config.configuration
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Rspec related metrics
|
|
38
|
-
#
|
|
39
|
-
# @return [Hash]
|
|
40
|
-
def rspec_metrics # rubocop:disable Metrics/AbcSize
|
|
41
|
-
{
|
|
42
|
-
id: without_relative_path(example.id),
|
|
43
|
-
name: example.full_description,
|
|
44
|
-
hash: OpenSSL::Digest.hexdigest("SHA256", "#{file_path}#{example.full_description}")[..40],
|
|
45
|
-
file_path: file_path,
|
|
46
|
-
status: example.execution_result.status,
|
|
47
|
-
run_time: (example.execution_result.run_time * 1000).round,
|
|
48
|
-
location: example_location,
|
|
49
|
-
# TODO: remove exception_class once migration to exception_classes is fully complete on clickhouse side
|
|
50
|
-
exception_class: example.execution_result.exception&.class&.to_s,
|
|
51
|
-
exception_classes: exception_classes.map { |e| e.class.to_s }.uniq,
|
|
52
|
-
failure_exception: failure_exception,
|
|
53
|
-
quarantined: quarantined?,
|
|
54
|
-
quarantine_issue_url: quarantine_issue_url || "",
|
|
55
|
-
feature_category: example.metadata[:feature_category] || "",
|
|
56
|
-
test_retried: config.test_retried_proc.call(example),
|
|
57
|
-
run_type: run_type,
|
|
58
|
-
spec_file_path_prefix: config.spec_file_path_prefix
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# CI related metrics
|
|
63
|
-
#
|
|
64
|
-
# @return [Hash]
|
|
65
|
-
def ci_metrics
|
|
66
|
-
{
|
|
67
|
-
ci_project_id: env("CI_PROJECT_ID")&.to_i,
|
|
68
|
-
ci_project_path: env("CI_PROJECT_PATH"),
|
|
69
|
-
ci_job_name: ci_job_name,
|
|
70
|
-
ci_job_id: env('CI_JOB_ID')&.to_i,
|
|
71
|
-
ci_pipeline_id: env('CI_PIPELINE_ID')&.to_i,
|
|
72
|
-
ci_merge_request_iid: (env('CI_MERGE_REQUEST_IID') || env('TOP_UPSTREAM_MERGE_REQUEST_IID'))&.to_i,
|
|
73
|
-
ci_branch: env("CI_COMMIT_REF_NAME"),
|
|
74
|
-
ci_target_branch: env("CI_MERGE_REQUEST_TARGET_BRANCH_NAME"),
|
|
75
|
-
ci_server_url: env("CI_SERVER_URL")
|
|
76
|
-
}
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Additional custom metrics
|
|
80
|
-
#
|
|
81
|
-
# @return [Hash]
|
|
82
|
-
def custom_metrics
|
|
83
|
-
metrics = example.metadata
|
|
84
|
-
.slice(*config.extra_rspec_metadata_keys)
|
|
85
|
-
.merge(config.custom_metrics_proc.call(example))
|
|
86
|
-
|
|
87
|
-
metrics.each_with_object({}) do |(k, value), custom_metrics|
|
|
88
|
-
custom_metrics[k.to_sym] = metrics_value(value)
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Checks if spec is quarantined
|
|
93
|
-
#
|
|
94
|
-
# @return [String]
|
|
95
|
-
def quarantined?
|
|
96
|
-
return false unless example.metadata.key?(:quarantine)
|
|
97
|
-
|
|
98
|
-
# if quarantine key is present and status is pending, consider it quarantined
|
|
99
|
-
example.execution_result.status == :pending
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Extract quarantine issue URL from metadata
|
|
103
|
-
#
|
|
104
|
-
# @return [String, nil]
|
|
105
|
-
def quarantine_issue_url
|
|
106
|
-
return nil unless example.metadata.key?(:quarantine)
|
|
107
|
-
|
|
108
|
-
metadata = example.metadata[:quarantine]
|
|
109
|
-
case metadata
|
|
110
|
-
when String
|
|
111
|
-
# Direct URL: quarantine: 'https://gitlab.com/.../issues/123'
|
|
112
|
-
metadata if metadata.start_with?('http')
|
|
113
|
-
when Hash
|
|
114
|
-
# Hash format: quarantine: { issue: 'https://...', reason: '...' }
|
|
115
|
-
issue = metadata[:issue] || metadata['issue']
|
|
116
|
-
# Handle array of URLs (take the first one)
|
|
117
|
-
issue.is_a?(Array) ? issue.first : issue
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Base ci job name
|
|
122
|
-
#
|
|
123
|
-
# @return [String]
|
|
124
|
-
def ci_job_name
|
|
125
|
-
env("CI_JOB_NAME")&.gsub(%r{ \d{1,2}/\d{1,2}}, '')
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# Example location
|
|
129
|
-
#
|
|
130
|
-
# @return [String]
|
|
131
|
-
def example_location
|
|
132
|
-
return @example_location if @example_location
|
|
133
|
-
|
|
134
|
-
# ensures that location will be correct even in case of shared examples
|
|
135
|
-
file = example
|
|
136
|
-
.metadata
|
|
137
|
-
.fetch(:shared_group_inclusion_backtrace)
|
|
138
|
-
.last
|
|
139
|
-
&.formatted_inclusion_location
|
|
140
|
-
|
|
141
|
-
return without_relative_path(example.location) unless file
|
|
142
|
-
|
|
143
|
-
@example_location = without_relative_path(file)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# File path based on actual test location, not shared example location
|
|
147
|
-
#
|
|
148
|
-
# @return [String]
|
|
149
|
-
def file_path
|
|
150
|
-
@file_path ||= example_location.gsub(/:\d+$/, "")
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Failure exception classes
|
|
154
|
-
#
|
|
155
|
-
# @return [Array<Exception>]
|
|
156
|
-
def exception_classes
|
|
157
|
-
exception = example.execution_result.exception
|
|
158
|
-
return [] unless exception
|
|
159
|
-
return [exception] unless exception.respond_to?(:all_exceptions)
|
|
160
|
-
|
|
161
|
-
exception.all_exceptions.flatten
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# Truncated exception message
|
|
165
|
-
#
|
|
166
|
-
# For MultipleExceptionError, returns the first wrapped exception's message
|
|
167
|
-
# instead of the unhelpful wrapper class name.
|
|
168
|
-
#
|
|
169
|
-
# @return [String]
|
|
170
|
-
def failure_exception
|
|
171
|
-
exception = example.execution_result.exception
|
|
172
|
-
return unless exception
|
|
173
|
-
|
|
174
|
-
source = if exception.respond_to?(:all_exceptions)
|
|
175
|
-
exception.all_exceptions.flatten.first || exception
|
|
176
|
-
else
|
|
177
|
-
exception
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
source.to_s.tr("\n", " ").slice(0, 1000)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Test run type | suite name
|
|
184
|
-
#
|
|
185
|
-
# @return [String]
|
|
186
|
-
def run_type
|
|
187
|
-
config.run_type || ci_job_name || "unknown"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Return non empty environment variable value
|
|
191
|
-
#
|
|
192
|
-
# @param [String] name
|
|
193
|
-
# @return [String, nil]
|
|
194
|
-
def env(name)
|
|
195
|
-
return unless ENV[name] && !ENV[name].empty?
|
|
196
|
-
|
|
197
|
-
ENV.fetch(name)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Metrics value cast to a valid type
|
|
201
|
-
#
|
|
202
|
-
# @param value [Object]
|
|
203
|
-
# @return [Object]
|
|
204
|
-
def metrics_value(value)
|
|
205
|
-
return value if value.is_a?(Numeric) || value.is_a?(String) || bool?(value) || value.nil?
|
|
206
|
-
|
|
207
|
-
value.to_s
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# Value is a true or false
|
|
211
|
-
#
|
|
212
|
-
# @param val [Object]
|
|
213
|
-
# @return [Boolean]
|
|
214
|
-
def bool?(val)
|
|
215
|
-
[true, false].include?(val)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Path without leading ./
|
|
219
|
-
#
|
|
220
|
-
# @param path [String]
|
|
221
|
-
# @return [String]
|
|
222
|
-
def without_relative_path(path)
|
|
223
|
-
path.gsub(%r{^\./}, "")
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
end
|