gitlab_quality-test_tooling 3.21.4 → 3.22.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc1eb2f60dd3f394c55bfbaff26309fbad61afd5eeceaa9c794092c1997f03eb
4
- data.tar.gz: 5bfe5172b7ef0212b31b3eba7eb2483d1bebe57d2627d8b0863e0b5df61d7348
3
+ metadata.gz: a2bfb7b97e0b3a58ac7cbf7cc5f4ab8eb49bf3bf117fce216a34996ed4b4e471
4
+ data.tar.gz: 93b21c6cf0608d72292514aa34770db9f7402813ac6b0aeafc383b74f48202b9
5
5
  SHA512:
6
- metadata.gz: 9b892716ac4868efafa4dfebea723db41e0eb62d4bf036bbc2b36d0cd79f9e1fd3d58a6e8104f8a8ecf4999dae3315cd9f4103f3ef4e43556f28a0a0192062a7
7
- data.tar.gz: 5c690dc16665ac12a38abb803af01399ded95c49c5b071127c7e0d67fc7e26dd1c9c87c9d122b8d3262f6e8ae7660a093238bbb8ba822d156caf9b6ed26f3abc
6
+ metadata.gz: b58cd4d33e53fccf07cd9ca8a1113f0e93a50f33a2217f46c50c3d4ec3e5a1e2f777fbd5546ec20813385a913e989f29b56fbb936be7a5b0bff1fe4f2ffd9231
7
+ data.tar.gz: 06a62a7194ebbdc04483a4691eef617c3e2d82d553851f82430ddef4634c0a53f1cb4d3e8cda82fb648bc57ef6a2dda581e4c774cc3eacb622473d7c86d6466d
data/.rubocop_todo.yml CHANGED
@@ -31,7 +31,6 @@ Layout/ClassStructure:
31
31
  Layout/LineEndStringConcatenationIndentation:
32
32
  Exclude:
33
33
  - 'lib/gitlab_quality/test_tooling/gitlab_client/merge_requests_dry_client.rb'
34
- - 'lib/gitlab_quality/test_tooling/report/report_results.rb'
35
34
  - 'spec/gitlab_quality/test_tooling/gitlab_client/issues_client_spec.rb'
36
35
  - 'spec/gitlab_quality/test_tooling/report/failed_test_issue_spec.rb'
37
36
  - 'spec/gitlab_quality/test_tooling/report/slow_test_issue_spec.rb'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab_quality-test_tooling (3.21.4)
4
+ gitlab_quality-test_tooling (3.22.0)
5
5
  activesupport (>= 7.0)
6
6
  amatch (~> 0.4.1)
7
7
  fog-google (~> 1.25)
data/README.md CHANGED
@@ -98,25 +98,6 @@ Usage: exe/relate-failure-issue [options]
98
98
  -h, --help Show the usage
99
99
  ```
100
100
 
101
- ### `exe/report-results`
102
-
103
- ```shell
104
- Purpose: Report test results from RSpec report files (JSON or JUnit XML) in GitLab test cases and result issues
105
- Usage: exe/report-results [options]
106
- -i, --input-files INPUT_FILES RSpec report files (JSON or JUnit XML)
107
- --test-case-project TEST_CASE_PROJECT
108
- Can be an integer or a group/project string
109
- -t TEST_CASE_PROJECT_TOKEN, A valid access token with `api` scope and Reporter permission in TEST_CASE_PROJECT
110
- --test-case-project-token
111
- --results-issue-project RESULTS_ISSUE_PROJECT
112
- Can be an integer or a group/project string
113
- -r RESULTS_ISSUE_PROJECT_TOKEN, A valid access token with `api` scope and Reporter permission in RESULTS_ISSUE_PROJECT
114
- --results-issue-project-token
115
- --dry-run Perform a dry-run (don't create/update issues or test cases)
116
- -v, --version Show the version
117
- -h, --help Show the usage
118
- ```
119
-
120
101
  ### `exe/update-screenshot-paths`
121
102
 
122
103
  ```shell
data/exe/test-coverage CHANGED
@@ -283,8 +283,7 @@ when :full_export
283
283
  # Export test-to-file mappings
284
284
  test_file_mapping_data = GitlabQuality::TestTooling::CodeCoverage::TestFileMappingData.new(
285
285
  test_to_sources,
286
- tests_to_categories: tests_to_categories,
287
- feature_categories_to_teams: category_owners.feature_categories_to_teams
286
+ tests_to_categories: tests_to_categories
288
287
  )
289
288
  test_file_mappings_table = GitlabQuality::TestTooling::CodeCoverage::ClickHouse::TestFileMappingsTable.new(**shared_clickhouse_data)
290
289
  test_file_mappings_table.push(test_file_mapping_data.as_db_table)
@@ -9,8 +9,7 @@ module GitlabQuality
9
9
  class TestFileMappingsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
10
10
  TABLE_NAME = "test_file_mappings"
11
11
 
12
- # Override push to filter out duplicate mappings before inserting
13
- # This prevents accumulating 36M duplicate rows per day in ReplacingMergeTree
12
+ # Override push to skip mappings already stored with the same values.
14
13
  #
15
14
  # @param data [Array<Hash>] Code coverage related data to be pushed to ClickHouse
16
15
  # @return [nil]
@@ -18,7 +17,9 @@ module GitlabQuality
18
17
  return logger.warn("#{LOG_PREFIX} No data found, skipping ClickHouse export!") if data.empty?
19
18
 
20
19
  logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
20
+ @dropped_source_files = []
21
21
  sanitized_data = sanitize(data)
22
+ report_dropped_source_files(sanitized_data.size)
22
23
 
23
24
  return logger.warn("#{LOG_PREFIX} No valid data found after sanitization, skipping ClickHouse export!") if sanitized_data.empty?
24
25
 
@@ -26,14 +27,14 @@ module GitlabQuality
26
27
  new_or_changed_records = filter_duplicates(sanitized_data)
27
28
 
28
29
  if new_or_changed_records.empty?
29
- logger.info("#{LOG_PREFIX} All #{sanitized_data.size} mappings already exist with same values, skipping insert to #{full_table_name}")
30
+ logger.info("#{LOG_PREFIX} None of the #{sanitized_data.size} mappings need writing to #{full_table_name}, skipping insert")
30
31
  return
31
32
  end
32
33
 
33
34
  client.insert_json_data(table_name, new_or_changed_records)
34
35
  skipped_count = sanitized_data.size - new_or_changed_records.size
35
36
  logger.info("#{LOG_PREFIX} Successfully pushed #{new_or_changed_records.size} new/changed records " \
36
- "to #{full_table_name} (skipped #{skipped_count} duplicates)")
37
+ "to #{full_table_name} (#{skipped_count} not written)")
37
38
  rescue StandardError => e
38
39
  logger.error("#{LOG_PREFIX} Error occurred while pushing data to #{full_table_name}: #{e.message}")
39
40
  raise
@@ -62,30 +63,74 @@ module GitlabQuality
62
63
  false
63
64
  end
64
65
 
65
- # @return [Hash] Transformed mapping data including timestamp and CI metadata
66
+ # @return [Hash, nil] Transformed mapping data including timestamp and CI metadata,
67
+ # or nil when the source path cannot be made repo-relative
66
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
+
67
76
  {
68
77
  timestamp: time,
69
78
  test_file: record[:test_file],
70
- source_file: record[:source_file],
79
+ source_file: source_file,
71
80
  # CI_PROJECT_PATH is set by GitLab CI runner and considered trusted infrastructure input.
72
81
  # In non-CI environments, this may be user-controlled, but sanitize_for_clickhouse
73
82
  # provides SQL injection protection.
74
83
  ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil),
75
- category: record[:category] || '',
76
- group: record[:group] || '',
77
- stage: record[:stage] || '',
78
- section: record[:section] || ''
84
+ category: record[:category] || ''
79
85
  }
80
86
  end
81
87
 
82
- # Filter out mappings that already exist in ClickHouse with identical enriched column values
83
- # Only returns records that are:
84
- # - New mappings (test_file + source_file combination doesn't exist), OR
85
- # - Changed mappings (enriched columns category/group/stage/section differ from existing)
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.
86
132
  #
87
- # This prevents inserting 36M duplicate rows per day while still allowing updates when
88
- # ownership (category/group/stage/section) changes over time.
133
+ # If the stored categories cannot be read, see #insertable_without_lookup.
89
134
  #
90
135
  # @param records [Array<Hash>] Sanitized records to insert
91
136
  # @return [Array<Hash>] Records that are new or have changed values
@@ -98,11 +143,11 @@ module GitlabQuality
98
143
 
99
144
  logger.debug("#{LOG_PREFIX} Checking for duplicate mappings in #{full_table_name}")
100
145
 
101
- # Build a hash of existing mappings with their current enriched column values
102
- # Key: "test_file|source_file", Value: {category, group, stage, section}
103
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
104
150
 
105
- # Filter records: keep only new or changed mappings
106
151
  new_or_changed = records.select do |record|
107
152
  key = "#{record[:test_file]}|#{record[:source_file]}"
108
153
  existing = existing_mappings[key]
@@ -110,27 +155,64 @@ module GitlabQuality
110
155
  # New mapping - doesn't exist yet
111
156
  next true if existing.nil?
112
157
 
113
- # Existing mapping - check if enriched columns changed
114
- existing[:category] != record[:category] ||
115
- existing[:group] != record[:group] ||
116
- existing[:stage] != record[:stage] ||
117
- existing[:section] != record[:section]
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]
118
168
  end
119
169
 
170
+ log_blank_suppressions(blank_suppressed)
120
171
  logger.debug("#{LOG_PREFIX} Found #{new_or_changed.size} new/changed mappings out of #{records.size} total")
121
172
  new_or_changed
122
173
  rescue StandardError => e
123
- # If duplicate detection fails, fall back to inserting all records (safer than failing completely)
124
- logger.warn("#{LOG_PREFIX} Failed to check for duplicates: #{e.message}. Inserting all records.")
125
- records
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")
126
207
  end
127
208
 
128
- # Fetch existing mappings from ClickHouse for the given project
129
- # Returns a hash mapping "test_file|source_file" => {category, group, stage, section}
209
+ # The category currently stored against each of this project's mappings. Only
210
+ # filter_duplicates reads it.
130
211
  #
131
212
  # @param ci_project_path [String] The CI project path to filter by
132
213
  # @param records [Array<Hash>] Records being inserted (used to limit query scope)
133
- # @return [Hash] Existing mappings with their enriched column values
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.
134
216
  def fetch_existing_mappings(ci_project_path, records) # rubocop:disable Metrics/AbcSize
135
217
  # Query for ALL latest mappings for this project
136
218
  # We filter in Ruby rather than building dynamic SQL to avoid injection risks
@@ -142,10 +224,7 @@ module GitlabQuality
142
224
  SELECT
143
225
  test_file,
144
226
  source_file,
145
- argMax(category, timestamp) AS category,
146
- argMax(`group`, timestamp) AS `group`,
147
- argMax(stage, timestamp) AS stage,
148
- argMax(section, timestamp) AS section
227
+ argMax(category, timestamp) AS category
149
228
  FROM #{full_table_name}
150
229
  WHERE ci_project_path = {project_path:String}
151
230
  GROUP BY test_file, source_file
@@ -163,16 +242,11 @@ module GitlabQuality
163
242
  next if test_files_set && !test_files_set.include?(row['test_file'])
164
243
 
165
244
  key = "#{row['test_file']}|#{row['source_file']}"
166
- hash[key] = {
167
- category: row['category'],
168
- group: row['group'],
169
- stage: row['stage'],
170
- section: row['section']
171
- }
245
+ hash[key] = { category: row['category'] }
172
246
  end
173
247
  rescue StandardError => e
174
248
  logger.warn("#{LOG_PREFIX} Failed to fetch existing mappings: #{e.message}")
175
- {} # Return empty hash on error (will treat all records as new)
249
+ nil # nil, not {}: an empty table and an unreadable one need different handling
176
250
  end
177
251
 
178
252
  # Sanitize string for ClickHouse by escaping single quotes and backslashes
@@ -8,34 +8,37 @@ module GitlabQuality
8
8
  # mapped to all source files they cover
9
9
  # @param [Hash<String, Array<String>>] tests_to_categories Test files
10
10
  # mapped to their feature categories
11
- # @param [Hash<String, Hash>] feature_categories_to_teams Feature categories
12
- # mapped to their org hierarchy (group, stage, section)
13
- def initialize(test_to_sources, tests_to_categories: {}, feature_categories_to_teams: {})
11
+ def initialize(test_to_sources, tests_to_categories: {})
14
12
  @test_to_sources = test_to_sources
15
13
  @tests_to_categories = tests_to_categories
16
- @feature_categories_to_teams = feature_categories_to_teams
17
14
  end
18
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
+ #
19
26
  # @return [Array<Hash<Symbol, String>>] Mapping data formatted for database insertion
20
27
  # @example Return value
21
28
  # [
22
29
  # { test_file: "spec/models/user_spec.rb", source_file: "app/models/user.rb",
23
- # category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
30
+ # category: "team_planning" },
24
31
  # ...
25
32
  # ]
26
33
  def as_db_table
27
34
  @test_to_sources.flat_map do |test_file, source_files|
28
35
  category = @tests_to_categories[test_file]&.first || ''
29
- team = @feature_categories_to_teams[category] || {}
30
36
 
31
37
  source_files.map do |source_file|
32
38
  {
33
39
  test_file: test_file,
34
40
  source_file: source_file,
35
- category: category,
36
- group: team[:group] || '',
37
- stage: team[:stage] || '',
38
- section: team[:section] || ''
41
+ category: category
39
42
  }
40
43
  end
41
44
  end
@@ -134,14 +134,6 @@ module GitlabQuality
134
134
  issue&.labels&.to_set || Set.new
135
135
  end
136
136
 
137
- def update_labels(issue, test, new_labels = Set.new)
138
- labels = up_to_date_labels(test: test, issue: issue, new_labels: new_labels)
139
-
140
- return if issue_labels(issue) == labels
141
-
142
- gitlab.edit_issue(iid: issue.iid, options: { labels: labels.to_a })
143
- end
144
-
145
137
  # Infer labels from the test, and optionally from the issue and new_labels in arguments
146
138
  #
147
139
  # Called when we're updating a test health issue with a new test report.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GitlabQuality
4
4
  module TestTooling
5
- VERSION = "3.21.4"
5
+ VERSION = "3.22.0"
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.21.4
4
+ version: 3.22.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-09-03 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: climate_control
@@ -437,7 +437,6 @@ executables:
437
437
  - post-to-slack
438
438
  - prepare-stage-reports
439
439
  - relate-failure-issue
440
- - report-results
441
440
  - slow-test-issues
442
441
  - slow-test-merge-request-report-note
443
442
  - sync-category-owners
@@ -471,7 +470,6 @@ files:
471
470
  - exe/post-to-slack
472
471
  - exe/prepare-stage-reports
473
472
  - exe/relate-failure-issue
474
- - exe/report-results
475
473
  - exe/slow-test-issues
476
474
  - exe/slow-test-merge-request-report-note
477
475
  - exe/sync-category-owners
@@ -539,7 +537,6 @@ files:
539
537
  - lib/gitlab_quality/test_tooling/labels_inference.rb
540
538
  - lib/gitlab_quality/test_tooling/report/concerns/group_and_category_labels.rb
541
539
  - lib/gitlab_quality/test_tooling/report/concerns/issue_reports.rb
542
- - lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb
543
540
  - lib/gitlab_quality/test_tooling/report/concerns/utils.rb
544
541
  - lib/gitlab_quality/test_tooling/report/failed_test_issue.rb
545
542
  - lib/gitlab_quality/test_tooling/report/feature_readiness/report_on_epic.rb
@@ -561,9 +558,6 @@ files:
561
558
  - lib/gitlab_quality/test_tooling/report/prepare_stage_reports.rb
562
559
  - lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb
563
560
  - lib/gitlab_quality/test_tooling/report/report_as_issue.rb
564
- - lib/gitlab_quality/test_tooling/report/report_results.rb
565
- - lib/gitlab_quality/test_tooling/report/results_in_issues.rb
566
- - lib/gitlab_quality/test_tooling/report/results_in_test_cases.rb
567
561
  - lib/gitlab_quality/test_tooling/report/slow_test_issue.rb
568
562
  - lib/gitlab_quality/test_tooling/report/update_screenshot_path.rb
569
563
  - lib/gitlab_quality/test_tooling/runtime/env.rb
data/exe/report-results DELETED
@@ -1,62 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "bundler/setup"
5
- require "optparse"
6
-
7
- require_relative "../lib/gitlab_quality/test_tooling"
8
-
9
- params = {}
10
-
11
- options = OptionParser.new do |opts|
12
- opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
13
-
14
- opts.on('-i', '--input-files INPUT_FILES', String, 'RSpec report files (JSON or JUnit XML)') do |input_files|
15
- params[:input_files] = input_files
16
- end
17
-
18
- opts.on('--test-case-project TEST_CASE_PROJECT', String,
19
- 'Can be an integer or a group/project string') do |test_case_project|
20
- params[:test_case_project] = test_case_project
21
- end
22
-
23
- opts.on('-t', '--test-case-project-token TEST_CASE_PROJECT_TOKEN', String,
24
- 'A valid access token with `api` scope and Reporter permission in TEST_CASE_PROJECT') do |test_case_project_token|
25
- params[:test_case_project_token] = test_case_project_token
26
- end
27
-
28
- opts.on('--results-issue-project RESULTS_ISSUE_PROJECT', String,
29
- 'Can be an integer or a group/project string') do |results_issue_project|
30
- params[:results_issue_project] = results_issue_project
31
- end
32
-
33
- opts.on('-r', '--results-issue-project-token RESULTS_ISSUE_PROJECT_TOKEN', String,
34
- 'A valid access token with `api` scope and Reporter permission in RESULTS_ISSUE_PROJECT') do |results_issue_project_token|
35
- params[:results_issue_project_token] = results_issue_project_token
36
- end
37
-
38
- opts.on('--dry-run', "Perform a dry-run (don't create/update issues or test cases)") do
39
- params[:dry_run] = true
40
- end
41
-
42
- opts.on_tail('-v', '--version', 'Show the version') do
43
- require_relative "../lib/gitlab_quality/test_tooling/version"
44
- puts "#{$PROGRAM_NAME} : #{GitlabQuality::TestTooling::VERSION}"
45
- exit
46
- end
47
-
48
- opts.on_tail('-h', '--help', 'Show the usage') do
49
- puts "Purpose: Report test results from RSpec report files (JSON or JUnit XML) in GitLab test cases and result issues"
50
- puts opts
51
- exit
52
- end
53
-
54
- opts.parse(ARGV)
55
- end
56
-
57
- if params.any?
58
- GitlabQuality::TestTooling::Report::ReportResults.new(**params).invoke!
59
- else
60
- puts options
61
- exit 1
62
- end
@@ -1,88 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_support/core_ext/enumerable'
4
-
5
- module GitlabQuality
6
- module TestTooling
7
- module Report
8
- module Concerns
9
- module ResultsReporter
10
- include Concerns::Utils
11
-
12
- TEST_CASE_RESULTS_SECTION_TEMPLATE = "\n\n### DO NOT EDIT BELOW THIS LINE\n\nActive and historical test results:"
13
-
14
- def find_issue(test)
15
- issues = search_for_issues(test)
16
-
17
- warn(%(Too many #{issue_type}s found with the file path "#{test.file}" and name "#{test.name}")) if issues.many?
18
-
19
- puts "Found existing #{issue_type}: #{issues.first.web_url}" unless issues.empty?
20
-
21
- issues.first
22
- end
23
-
24
- def find_issue_by_iid(iid)
25
- issues = gitlab.find_issues(iid: iid) do |issue|
26
- issue.state == 'opened' && issue.issue_type == issue_type
27
- end
28
-
29
- warn(%(#{issue_type} iid "#{iid}" not valid)) if issues.empty?
30
-
31
- issues.first
32
- end
33
-
34
- def issue_title_needs_updating?(issue, test)
35
- issue.title.strip != title_from_test(test) && !%w[canary production preprod release].include?(pipeline)
36
- end
37
-
38
- def new_issue_labels(_test)
39
- %w[E2E status::automated suppress-contributor-links]
40
- end
41
-
42
- def up_to_date_labels(test:, issue: nil, new_labels: Set.new)
43
- labels = super
44
- labels |= new_issue_labels(test).to_set
45
- labels.delete_if { |label| label.start_with?("#{pipeline}::") }
46
- labels << (test.failures.empty? ? "#{pipeline}::passed" : "#{pipeline}::failed")
47
- end
48
-
49
- def update_issue(issue, test)
50
- update_params = {}
51
-
52
- old_title = issue.title.strip
53
- new_title = title_from_test(test)
54
-
55
- if old_title != new_title
56
- update_params[:title] = new_title
57
- warn(%(#{issue_type} title needs to be updated from '#{old_title}' to '#{new_title}'))
58
- end
59
-
60
- old_description = issue.description
61
- new_description = updated_description(issue, test)
62
-
63
- if old_description != new_description
64
- warn(%(#{issue_type} description needs to be updated from '#{old_description}' to '#{new_description}'))
65
- update_params[:description] = new_description
66
- end
67
-
68
- return if update_params.empty?
69
-
70
- gitlab.edit_issue(iid: issue.iid, options: update_params)
71
- end
72
-
73
- private
74
-
75
- def search_term(test)
76
- %("#{partial_file_path(test.file)}" "#{search_safe(test.name)}")
77
- end
78
-
79
- def search_for_issues(test)
80
- gitlab.find_issues(options: { search: search_term(test) }) do |issue|
81
- issue.state == 'opened' && issue.issue_type == issue_type && issue.title.strip == title_from_test(test)
82
- end
83
- end
84
- end
85
- end
86
- end
87
- end
88
- end
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GitlabQuality
4
- module TestTooling
5
- module Report
6
- # Uses the API to:
7
- # - create or update test cases
8
- # - create or update issues
9
- # based on the results of tests from RSpec run result files.
10
- class ReportResults < ReportAsIssue
11
- attr_accessor :testcase_project_reporter, :results_issue_project_reporter, :files, :test_case_project,
12
- :results_issue_project, :gitlab
13
-
14
- def initialize(
15
- test_case_project_token:, results_issue_project_token:, input_files:, test_case_project: nil, results_issue_project: nil, dry_run: false,
16
- **kwargs)
17
- @test_case_project_token = test_case_project_token
18
- @testcase_project_reporter = GitlabQuality::TestTooling::Report::ResultsInTestCases.new(
19
- token: test_case_project_token, input_files: input_files, project: test_case_project, dry_run: dry_run, **kwargs)
20
- @results_issue_project_reporter = GitlabQuality::TestTooling::Report::ResultsInIssues.new(
21
- token: results_issue_project_token, input_files: input_files, project: results_issue_project, dry_run: dry_run, **kwargs)
22
- @test_case_project = test_case_project
23
- @results_issue_project = results_issue_project
24
- @files = Array(input_files)
25
- @gitlab = testcase_project_reporter.gitlab
26
- end
27
-
28
- def validate_input!
29
- assert_input_files!(files)
30
- gitlab.assert_user_permission!
31
- end
32
-
33
- private
34
-
35
- attr_reader :test_case_project_token
36
-
37
- # rubocop:disable Metrics/AbcSize
38
- def run!
39
- puts "Reporting test results in `#{files.join(',')}` as test cases in project `#{test_case_project}` " \
40
- "and issues in project `#{results_issue_project}` via the API at `#{Runtime::Env.gitlab_api_base}`."
41
-
42
- TestResults::Builder.new(token: test_case_project_token, project: test_case_project, file_glob: files).test_results_per_file do |test_results|
43
- puts "Reporting tests in #{test_results.path}"
44
-
45
- test_results.each do |test|
46
- next if test.file.include?('/features/sanity/') || test.skipped?
47
-
48
- puts "Reporting test: #{test.relative_file} | #{test.name}\n"
49
-
50
- report_test(test)
51
- end
52
- end
53
- end
54
-
55
- def report_test(test)
56
- testcase = testcase_project_reporter.find_or_create_testcase(test)
57
-
58
- result_issue, is_new = results_issue_project_reporter.get_related_issue(testcase, test)
59
-
60
- testcase_project_reporter.add_result_issue_link_to_testcase(testcase, result_issue, test) if is_new
61
-
62
- testcase_project_reporter.update_testcase(testcase, test)
63
-
64
- labels_updated = results_issue_project_reporter.update_issue_labels(result_issue, test)
65
- note_posted = results_issue_project_reporter.post_note(result_issue, test)
66
-
67
- if labels_updated || note_posted
68
- puts "Issue updated: #{result_issue.web_url}"
69
- else
70
- puts "Test passed, no results issue update needed."
71
- end
72
- end
73
- # rubocop:enable Metrics/AbcSize
74
- end
75
- end
76
- end
77
- end
@@ -1,123 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GitlabQuality
4
- module TestTooling
5
- module Report
6
- # Uses the API to create or update GitLab test result issues with the results of tests from RSpec report files.
7
- class ResultsInIssues < ReportAsIssue
8
- include Concerns::ResultsReporter
9
-
10
- def initialize(**kwargs)
11
- super
12
- @issue_type = 'issue'
13
- end
14
-
15
- def get_related_issue(testcase, test)
16
- issue = find_linked_results_issue_by_iid(testcase, test)
17
- is_new = false
18
-
19
- if issue
20
- issue = update_issue(issue, test) if issue_title_needs_updating?(issue, test)
21
- else
22
- puts "No valid issue link found."
23
- issue = find_or_create_results_issue(test)
24
- is_new = true
25
- end
26
-
27
- [issue, is_new]
28
- end
29
-
30
- def update_issue_labels(issue, test)
31
- new_labels = issue_labels(issue)
32
- new_labels |= ['Testcase Linked']
33
-
34
- update_labels(issue, test, new_labels)
35
- end
36
-
37
- def post_note(issue, test)
38
- return false if test.skipped?
39
- return false if test.failures.empty?
40
-
41
- note = note_content(test)
42
-
43
- gitlab.find_issue_discussions(iid: issue.iid).each do |discussion|
44
- next unless new_note_matches_discussion?(note, discussion)
45
-
46
- gitlab.add_note_to_issue_discussion_as_thread(
47
- iid: issue.iid,
48
- discussion_id: discussion.id,
49
- note: failure_summary)
50
- return true
51
- end
52
-
53
- gitlab.create_issue_note(iid: issue.iid, note: note)
54
- end
55
-
56
- private
57
-
58
- def find_linked_results_issue_by_iid(testcase, _test)
59
- iid = issue_iid_from_testcase(testcase)
60
-
61
- return unless iid
62
-
63
- find_issue_by_iid(iid)
64
- end
65
-
66
- def find_or_create_results_issue(test)
67
- find_issue(test) || create_issue(test)
68
- end
69
-
70
- def issue_iid_from_testcase(testcase)
71
- results = testcase.description.partition(TEST_CASE_RESULTS_SECTION_TEMPLATE).last if testcase.description.include?(TEST_CASE_RESULTS_SECTION_TEMPLATE)
72
-
73
- return unless results
74
-
75
- issue_iid = results.split('/').last
76
-
77
- issue_iid&.to_i
78
- end
79
-
80
- def note_content(test)
81
- errors = test.failures.each_with_object([]) do |failure, text|
82
- text << <<~TEXT
83
- Error:
84
- ```
85
- #{failure['message']}
86
- ```
87
-
88
- Stacktrace:
89
- ```
90
- #{failure['stacktrace']}
91
- ```
92
- TEXT
93
- end.join("\n\n")
94
-
95
- "#{failure_summary}\n\n#{errors}"
96
- end
97
-
98
- def failure_summary
99
- summary = [%(:x: ~"#{pipeline}::failed")]
100
- summary << "in job `#{Runtime::Env.ci_job_name}` in #{Runtime::Env.ci_job_url}"
101
- summary.join(' ')
102
- end
103
-
104
- def new_note_matches_discussion?(note, discussion)
105
- note_error = error_and_stack_trace(note)
106
- discussion_error = error_and_stack_trace(discussion.notes.first['body'])
107
-
108
- return false if note_error.empty? || discussion_error.empty?
109
-
110
- note_error == discussion_error
111
- end
112
-
113
- def error_and_stack_trace(text)
114
- text.strip[/Error:(.*)/m, 1].to_s
115
- end
116
-
117
- def updated_description(_issue, test)
118
- new_issue_description(test)
119
- end
120
- end
121
- end
122
- end
123
- end
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GitlabQuality
4
- module TestTooling
5
- module Report
6
- # Uses the API to create or update GitLab test cases with the results of tests from RSpec report files.
7
- class ResultsInTestCases < ReportAsIssue
8
- include Concerns::ResultsReporter
9
-
10
- attr_reader :issue_type, :gitlab
11
-
12
- def initialize(**kwargs)
13
- super
14
- @issue_type = 'test_case'
15
- end
16
-
17
- def find_or_create_testcase(test)
18
- find_testcase(test) || create_issue(test)
19
- end
20
-
21
- def add_result_issue_link_to_testcase(testcase, result_issue, _test)
22
- results_section = testcase.description.include?(TEST_CASE_RESULTS_SECTION_TEMPLATE) ? '' : TEST_CASE_RESULTS_SECTION_TEMPLATE
23
-
24
- gitlab.edit_issue(iid: testcase.iid,
25
- options: { description: (testcase.description + results_section + "\n\n#{result_issue.web_url}") })
26
- puts "Added results issue #{result_issue.web_url} link to test case #{testcase.web_url}"
27
- end
28
-
29
- def update_testcase(testcase, test)
30
- puts "Labels updated for test case #{testcase.web_url}." if update_labels(testcase, test)
31
- puts "Quarantine section updated for test case #{testcase.web_url}." if update_quarantine_section(testcase, test)
32
- end
33
-
34
- private
35
-
36
- def find_testcase(test)
37
- testcase = find_issue(test)
38
- return unless testcase
39
-
40
- if testcase_needs_updating?(testcase, test)
41
- update_issue(testcase, test)
42
- else
43
- testcase
44
- end
45
- end
46
-
47
- def new_issue_description(test)
48
- quarantine_section = test.quarantine? && test.quarantine_issue ? "\n\n### Quarantine issue\n\n#{test.quarantine_issue}" : ''
49
-
50
- "#{super}#{quarantine_section}\n\n#{execution_graph_section(test)}"
51
- end
52
-
53
- def execution_graph_section(test)
54
- formatted_path = CGI.escape(test.relative_file)
55
-
56
- <<~MKDOWN.strip
57
- ### Executions
58
-
59
- [Spec metrics on all environments](https://dashboards.devex.gitlab.net/d/739c1bdd-a436-452b-bddc-fccb4d055768/single-test-overview?var-file_path=#{formatted_path})
60
- MKDOWN
61
- end
62
-
63
- def updated_description(testcase, test)
64
- historical_results_section = testcase.description.match(/### DO NOT EDIT BELOW THIS LINE[\s\S]+/)
65
-
66
- "#{new_issue_description(test)}\n\n#{historical_results_section}"
67
- end
68
-
69
- def testcase_needs_updating?(testcase, test)
70
- return false if %w[canary production preprod release].include?(pipeline)
71
- return true if issue_title_needs_updating?(testcase, test)
72
-
73
- !testcase.description.include?(execution_graph_section(test))
74
- end
75
-
76
- def quarantine_section_needs_updating?(testcase, test)
77
- if test.quarantine? && test.quarantine_issue
78
- return false if testcase.description.include?(test.quarantine_issue)
79
- else
80
- return false unless testcase.description.include?('Quarantine issue')
81
- end
82
-
83
- true
84
- end
85
-
86
- def update_quarantine_section(testcase, test)
87
- return unless quarantine_section_needs_updating?(testcase, test)
88
-
89
- new_description = updated_description(testcase, test)
90
-
91
- gitlab.edit_issue(iid: testcase.iid, options: { description: new_description })
92
- end
93
- end
94
- end
95
- end
96
- end