gitlab_quality-test_tooling 3.23.0 → 3.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 635939993ec2f8ac5d2593a070f8494cf9953510c3d2b1f659a86afadd57ff66
4
- data.tar.gz: 7a75e161d96b24793411864ab93a467bad63bab716d2b1e2a5d1ab272583a9b9
3
+ metadata.gz: 49a063bdf7dc0b0055d926d178df7a5274c067f352a5feae0d5f04695702ea01
4
+ data.tar.gz: 426701ca7b4f8b55c433bfd78b173f1e731c1f55dafd099584acb0366b6f77de
5
5
  SHA512:
6
- metadata.gz: 47a4fb04a69850d455d1dde34a2fdf3026183590d839301c07bad0685c41a8c244e128a40006dd7b90fcf83175e873b01f258945073d03e1cc9c77fe3ee6f37a
7
- data.tar.gz: bf09187090fd25886a6c4a0eb320b1d431db0df8a6eca497f09b178245ffb9c229a8ba0c84107016a036eb5f095b01e709dfadc136a0393864ef1b45445df65d
6
+ metadata.gz: a3a5eb95bc469a879dacb6db1e3a456b35373391ed034df6a4adec4840533666613367de04510f4a8b9e1c60f755bfc5140b6a32048a597ec822bfc1c10cfd92
7
+ data.tar.gz: 00136e319209d433a0ff36c7c4a583ea54848653a5b1290244ba8d9108e47c0e3f7aad4a736cd7f3b51d42afe8d3beb16074c655cb41939f0135e555cfd96efb
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (3.23.0)
4
+ gitlab_quality-test_tooling (3.23.1)
5
5
  activesupport (>= 7.0)
6
6
  amatch (~> 0.4.1)
7
7
  csv (~> 3.0)
data/README.md CHANGED
@@ -248,7 +248,7 @@ Usage: exe/feature-readiness-evaluation [options]
248
248
 
249
249
  Runs in one of three modes:
250
250
 
251
- - **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.
251
+ - **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 table. Requires the full argument set.
252
252
  - **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.
253
253
  - **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.
254
254
 
data/exe/post-to-slack CHANGED
@@ -15,11 +15,12 @@ gitlab_api_token = nil
15
15
  options = OptionParser.new do |opts|
16
16
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
17
17
 
18
- opts.on('-w', '--slack-webhook-url SLACK_WEBHOOK_URL', String, 'Slack webhook URL') do |slack_webhook_url|
19
- params[:slack_webhook_url] = slack_webhook_url
18
+ # No String type on the webhook URL and bot token: OptionParser's String rejects "",
19
+ # and CI passes an unset variable as "".
20
+ opts.on('-w', '--slack-webhook-url SLACK_WEBHOOK_URL', 'Slack webhook URL') do |slack_webhook_url|
21
+ params[:slack_webhook_url] = slack_webhook_url unless slack_webhook_url.empty?
20
22
  end
21
23
 
22
- # No String type: OptionParser's String rejects "", and CI passes an unset variable as "".
23
24
  opts.on('-b', '--slack-bot-token TOKEN', 'Slack bot token, used instead of the webhook URL when set') do |token|
24
25
  params[:slack_bot_token] = token unless token.empty?
25
26
  end
data/exe/test-coverage CHANGED
@@ -11,7 +11,6 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/category_owne
11
11
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table'
12
12
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table'
13
13
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/per_test_coverage_table'
14
- require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table'
15
14
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/jest_quarantined_tests_table'
16
15
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregator'
17
16
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_score_snapshotter'
@@ -20,7 +19,6 @@ require_relative '../lib/gitlab_quality/test_tooling/code_coverage/lcov_file'
20
19
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/artifacts'
21
20
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_report'
22
21
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_map'
23
- require_relative '../lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data'
24
22
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data'
25
23
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_exporter'
26
24
  require_relative '../lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier'
@@ -279,12 +277,4 @@ when :full_export
279
277
  **clickhouse_data
280
278
  )
281
279
  coverage_metrics_table.push(coverage_data.as_db_table)
282
-
283
- # Export test-to-file mappings
284
- test_file_mapping_data = GitlabQuality::TestTooling::CodeCoverage::TestFileMappingData.new(
285
- test_to_sources,
286
- tests_to_categories: tests_to_categories
287
- )
288
- test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**shared_clickhouse_data)
289
- test_file_mappings_table.push(test_file_mapping_data.as_db_table)
290
280
  end
@@ -135,15 +135,6 @@ dependent:
135
135
  - "^spec/features/"
136
136
  ```
137
137
 
138
- ## Test-to-File Mappings
139
-
140
- When a test map is provided, the module also exports test-to-source-file relationships
141
- to a separate `test_file_mappings` table. This enables:
142
-
143
- - **Coverage context for tests** - see which source files a specific test covers
144
- - **Impact analysis** - understand which files would lose coverage if a test is quarantined
145
- - **Flaky test triage** - correlate flaky tests with the source files they cover
146
-
147
138
  ## CLI
148
139
 
149
140
  Example usage:
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "3.23.0"
5
+ VERSION = "3.23.1"
6
6
  end
7
7
  end
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.23.0
4
+ version: 3.23.1
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-10-23 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -502,7 +502,6 @@ files:
502
502
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/jest_quarantined_tests_table.rb
503
503
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/per_test_coverage_table.rb
504
504
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb
505
- - lib/gitlab_quality/test_tooling/code_coverage/click_house/test_file_mappings_table.rb
506
505
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregation.sql
507
506
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_risk_aggregator.rb
508
507
  - lib/gitlab_quality/test_tooling/code_coverage/click_house/test_health_score_snapshot.sql
@@ -515,7 +514,6 @@ files:
515
514
  - lib/gitlab_quality/test_tooling/code_coverage/responsibility_patterns_config.rb
516
515
  - lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb
517
516
  - lib/gitlab_quality/test_tooling/code_coverage/source_file_classifier.rb
518
- - lib/gitlab_quality/test_tooling/code_coverage/test_file_mapping_data.rb
519
517
  - lib/gitlab_quality/test_tooling/code_coverage/test_map.rb
520
518
  - lib/gitlab_quality/test_tooling/code_coverage/test_report.rb
521
519
  - lib/gitlab_quality/test_tooling/code_coverage/utils.rb
@@ -1,263 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'table'
4
-
5
- module GitlabQuality
6
- module TestTooling
7
- module CodeCoverage
8
- module ClickHouse
9
- class TestFileMappingsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
10
- TABLE_NAME = "test_file_mappings"
11
-
12
- # Override push to skip mappings already stored with the same values.
13
- #
14
- # @param data [Array<Hash>] Code coverage related data to be pushed to ClickHouse
15
- # @return [nil]
16
- def push(data) # rubocop:disable Metrics/AbcSize
17
- return logger.warn("#{LOG_PREFIX} No data found, skipping ClickHouse export!") if data.empty?
18
-
19
- logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
20
- @dropped_source_files = []
21
- sanitized_data = sanitize(data)
22
- report_dropped_source_files(sanitized_data.size)
23
-
24
- return logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
25
-
26
- # Filter out records that already exist with identical values
27
- new_or_changed_records = filter_duplicates(sanitized_data)
28
-
29
- if new_or_changed_records.empty?
30
- logger.info("#{LOG_PREFIX} None of the #{sanitized_data.size} mappings need writing to #{full_table_name}, skipping insert")
31
- return
32
- end
33
-
34
- client.insert_json_data(table_name, new_or_changed_records)
35
- skipped_count = sanitized_data.size - new_or_changed_records.size
36
- logger.info("#{LOG_PREFIX} Successfully pushed #{new_or_changed_records.size} new/changed records " \
37
- "to #{full_table_name} (#{skipped_count} not written)")
38
- rescue StandardError => e
39
- logger.error("#{LOG_PREFIX} Error occurred while pushing data to #{full_table_name}: #{e.message}")
40
- raise
41
- end
42
-
43
- private
44
-
45
- # @return [Boolean] True if the record is valid, false otherwise
46
- def valid_record?(record)
47
- valid_test_file?(record) && valid_source_file?(record)
48
- end
49
-
50
- # @return [Boolean] True if the test_file field is present
51
- def valid_test_file?(record)
52
- return true unless record[:test_file].blank?
53
-
54
- logger.warn("#{LOG_PREFIX} Skipping record with nil/empty test_file: #{record}")
55
- false
56
- end
57
-
58
- # @return [Boolean] True if the source_file field is present
59
- def valid_source_file?(record)
60
- return true unless record[:source_file].blank?
61
-
62
- logger.warn("#{LOG_PREFIX} Skipping record with nil/empty source_file: #{record}")
63
- false
64
- end
65
-
66
- # @return [Hash, nil] Transformed mapping data including timestamp and CI metadata,
67
- # or nil when the source path cannot be made repo-relative
68
- def sanitized_data_record(record)
69
- source_file = relative_source_file(record[:source_file])
70
-
71
- if source_file.nil?
72
- dropped_source_files << record[:source_file]
73
- return
74
- end
75
-
76
- {
77
- timestamp: time,
78
- test_file: record[:test_file],
79
- source_file: source_file,
80
- # CI_PROJECT_PATH is set by GitLab CI runner and considered trusted infrastructure input.
81
- # In non-CI environments, this may be user-controlled, but sanitize_for_clickhouse
82
- # provides SQL injection protection.
83
- ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil),
84
- category: record[:category] || ''
85
- }
86
- end
87
-
88
- def dropped_source_files
89
- @dropped_source_files ||= []
90
- end
91
-
92
- # Reports the records that relative_source_file could not make repo-relative, once
93
- # for the whole run, and raises when none survived. A CI_PROJECT_DIR that does not
94
- # match the artifact's paths drops every mapping, so warning per record buries the
95
- # job trace in one line per mapping, and warning alone would let a wholly empty
96
- # export finish green.
97
- #
98
- # @return [void]
99
- def report_dropped_source_files(kept_count)
100
- return if dropped_source_files.empty?
101
-
102
- message = "#{LOG_PREFIX} Skipped #{dropped_source_files.size} records with " \
103
- "non-relative source_file, samples: #{dropped_source_files.first(3).join(', ')}"
104
-
105
- raise message if kept_count.zero?
106
-
107
- logger.warn(message)
108
- end
109
-
110
- # The repo-relative form of a source path, which is what the table's
111
- # valid_source_file_format constraint accepts. Returns nil when the path cannot be
112
- # made relative or normalizes to nothing, and the caller drops that record, because
113
- # ClickHouse rejects the whole insert batch on a constraint violation rather than
114
- # the offending row.
115
- #
116
- # @return [String, nil]
117
- def relative_source_file(path)
118
- ci_project_dir = ENV.fetch('CI_PROJECT_DIR', nil)
119
-
120
- relative = path
121
- relative = relative.delete_prefix("#{ci_project_dir.chomp('/')}/") if ci_project_dir.present?
122
- relative = relative.sub(%r{\A(\./)+}, '')
123
-
124
- relative.presence unless relative.start_with?('/')
125
- end
126
-
127
- # The records worth writing: mappings this project has not stored yet, plus ones
128
- # whose category changed, except that a blank never replaces a stored category.
129
- # Re-sending an unchanged mapping costs a row that survives until the table's TTL
130
- # expires it, because section and stage are still part of the sort key and the
131
- # values written here no longer match the stored ones.
132
- #
133
- # If the stored categories cannot be read, see #insertable_without_lookup.
134
- #
135
- # @param records [Array<Hash>] Sanitized records to insert
136
- # @return [Array<Hash>] Records that are new or have changed values
137
- def filter_duplicates(records) # rubocop:disable Metrics/AbcSize
138
- return records if records.empty?
139
-
140
- # Assumes all records belong to the same project (batch from single pipeline run)
141
- ci_project_path = records.first[:ci_project_path]
142
- return records if ci_project_path.blank? # Can't query without project path
143
-
144
- logger.debug("#{LOG_PREFIX} Checking for duplicate mappings in #{full_table_name}")
145
-
146
- existing_mappings = fetch_existing_mappings(ci_project_path, records)
147
- return insertable_without_lookup(records) if existing_mappings.nil?
148
-
149
- blank_suppressed = 0
150
-
151
- new_or_changed = records.select do |record|
152
- key = "#{record[:test_file]}|#{record[:source_file]}"
153
- existing = existing_mappings[key]
154
-
155
- # New mapping - doesn't exist yet
156
- next true if existing.nil?
157
-
158
- # A blank category means this pipeline's rspec reports didn't cover the test,
159
- # not that the test has no category: the mappings come from the Crystalball map,
160
- # which lists every test file regardless of what ran. Writing the blank erases a
161
- # known category, and readers resolving ownership through category get nothing.
162
- if record[:category].blank? && existing[:category].present?
163
- blank_suppressed += 1
164
- next false
165
- end
166
-
167
- existing[:category] != record[:category]
168
- end
169
-
170
- log_blank_suppressions(blank_suppressed)
171
- logger.debug("#{LOG_PREFIX} Found #{new_or_changed.size} new/changed mappings out of #{records.size} total")
172
- new_or_changed
173
- rescue StandardError => e
174
- logger.warn("#{LOG_PREFIX} Failed to check for duplicates: #{e.message}")
175
- insertable_without_lookup(records)
176
- end
177
-
178
- # What to write when the stored categories could not be read. Records carrying a
179
- # category still go in, because a failed read must not silently drop mappings.
180
- # Blanks are held back: with no stored value to compare against they look new, and
181
- # writing one replaces whatever category is already there. The mapping comes back
182
- # on the next export that can read.
183
- #
184
- # @param records [Array<Hash>] Sanitized records to insert
185
- # @return [Array<Hash>] Records that carry a category
186
- def insertable_without_lookup(records)
187
- with_category = records.reject { |record| record[:category].blank? }
188
-
189
- logger.warn("#{LOG_PREFIX} No duplicate check possible; inserting " \
190
- "#{with_category.size} of #{records.size} mappings and holding back " \
191
- "#{records.size - with_category.size} with no category")
192
-
193
- with_category
194
- end
195
-
196
- # Reported at info because it is the only signal that a pipeline's reports were
197
- # incomplete. A steady non-zero count across runs means categories are arriving
198
- # from fewer reports than there are test files in the map.
199
- #
200
- # @param count [Integer] Mappings whose stored category was kept over an incoming blank
201
- # @return [nil]
202
- def log_blank_suppressions(count)
203
- return unless count.positive?
204
-
205
- logger.info("#{LOG_PREFIX} Kept the stored category for #{count} mappings " \
206
- "this pipeline's reports did not cover")
207
- end
208
-
209
- # The category currently stored against each of this project's mappings. Only
210
- # filter_duplicates reads it.
211
- #
212
- # @param ci_project_path [String] The CI project path to filter by
213
- # @param records [Array<Hash>] Records being inserted (used to limit query scope)
214
- # @return [Hash, nil] "test_file|source_file" => { category: String }, or nil when
215
- # the read fails. An empty Hash means the table holds nothing for this project.
216
- def fetch_existing_mappings(ci_project_path, records) # rubocop:disable Metrics/AbcSize
217
- # Query for ALL latest mappings for this project
218
- # We filter in Ruby rather than building dynamic SQL to avoid injection risks
219
- # Aggregate rather than matching rows against a set of the latest timestamps.
220
- # The set form has to materialise one tuple per stored mapping before it can
221
- # filter anything, which exceeds the server's memory limit at this table's
222
- # size; argMax streams and peaks at roughly a quarter of it.
223
- sql = <<~SQL
224
- SELECT
225
- test_file,
226
- source_file,
227
- argMax(category, timestamp) AS category
228
- FROM #{full_table_name}
229
- WHERE ci_project_path = {project_path:String}
230
- GROUP BY test_file, source_file
231
- SQL
232
-
233
- # Substitute project_path value using manual escaping (client doesn't support parameterized queries)
234
- sql_with_params = sql.strip.gsub('{project_path:String}', "'#{sanitize_for_clickhouse(ci_project_path)}'")
235
- results = client.query(sql_with_params)
236
-
237
- # Build lookup hash, filtering to only the test_files we're checking if dataset is small
238
- test_files_set = records.to_set { |r| r[:test_file] } if records.size <= 10_000
239
-
240
- results.each_with_object({}) do |row, hash|
241
- # Skip if we're doing selective filtering and this test_file isn't in our set
242
- next if test_files_set && !test_files_set.include?(row['test_file'])
243
-
244
- key = "#{row['test_file']}|#{row['source_file']}"
245
- hash[key] = { category: row['category'] }
246
- end
247
- rescue StandardError => e
248
- logger.warn("#{LOG_PREFIX} Failed to fetch existing mappings: #{e.message}")
249
- nil # nil, not {}: an empty table and an unreadable one need different handling
250
- end
251
-
252
- # Sanitize string for ClickHouse by escaping single quotes and backslashes
253
- # This protects against SQL injection when we must use string interpolation
254
- # @param str [String] String to sanitize
255
- # @return [String] Sanitized string
256
- def sanitize_for_clickhouse(str)
257
- str.to_s.gsub(/\\/, '\\\\\\\\').gsub("'", "''") # rubocop:disable Style/RedundantRegexpArgument
258
- end
259
- end
260
- end
261
- end
262
- end
263
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GitlabQuality
4
- module TestTooling
5
- module CodeCoverage
6
- class TestFileMappingData
7
- # @param [Hash<String, Array<String>>] test_to_sources Test files
8
- # mapped to all source files they cover
9
- # @param [Hash<String, Array<String>>] tests_to_categories Test files
10
- # mapped to their feature categories
11
- def initialize(test_to_sources, tests_to_categories: {})
12
- @test_to_sources = test_to_sources
13
- @tests_to_categories = tests_to_categories
14
- end
15
-
16
- # Rows for shared.test_file_mappings, one per test-and-source pair.
17
- #
18
- # Carries no group, stage or section on purpose. Those describe the category
19
- # rather than the mapping and change whenever stages.yml is reorganised, so
20
- # storing them on every mapping multiplies rows for no gain.
21
- #
22
- # Readers resolve ownership from shared.category_owners through category. That
23
- # table keeps every historical row, up to 57 for one category, so a plain join
24
- # fans out; dedupe it with ORDER BY category, timestamp DESC LIMIT 1 BY category.
25
- #
26
- # @return [Array<Hash<Symbol, String>>] Mapping data formatted for database insertion
27
- # @example Return value
28
- # [
29
- # { test_file: "spec/models/user_spec.rb", source_file: "app/models/user.rb",
30
- # category: "team_planning" },
31
- # ...
32
- # ]
33
- def as_db_table
34
- @test_to_sources.flat_map do |test_file, source_files|
35
- category = @tests_to_categories[test_file]&.first || ''
36
-
37
- source_files.map do |source_file|
38
- {
39
- test_file: test_file,
40
- source_file: source_file,
41
- category: category
42
- }
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end
49
- end