gitlab_quality-test_tooling 2.16.0 → 2.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/.tool-versions +1 -1
  4. data/Gemfile.lock +30 -28
  5. data/README.md +1 -1
  6. data/exe/epic-readiness-notification +58 -0
  7. data/exe/post-to-slack +4 -0
  8. data/exe/relate-failure-issue +9 -0
  9. data/exe/test-coverage +113 -0
  10. data/lib/gitlab_quality/test_tooling/click_house/client.rb +111 -0
  11. data/lib/gitlab_quality/test_tooling/code_coverage/artifacts.rb +77 -0
  12. data/lib/gitlab_quality/test_tooling/code_coverage/category_owners.rb +158 -0
  13. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/category_owners_table.rb +62 -0
  14. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/coverage_metrics_table.rb +109 -0
  15. data/lib/gitlab_quality/test_tooling/code_coverage/click_house/table.rb +73 -0
  16. data/lib/gitlab_quality/test_tooling/code_coverage/coverage_data.rb +82 -0
  17. data/lib/gitlab_quality/test_tooling/code_coverage/lcov_file.rb +91 -0
  18. data/lib/gitlab_quality/test_tooling/code_coverage/rspec_report.rb +43 -0
  19. data/lib/gitlab_quality/test_tooling/code_coverage/test_map.rb +93 -0
  20. data/lib/gitlab_quality/test_tooling/code_coverage/utils.rb +18 -0
  21. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/issue_concern.rb +1 -1
  22. data/lib/gitlab_quality/test_tooling/feature_readiness/concerns/work_item_concern.rb +11 -0
  23. data/lib/gitlab_quality/test_tooling/feature_readiness/epic_readiness_notifier.rb +308 -0
  24. data/lib/gitlab_quality/test_tooling/gcs_tools.rb +49 -0
  25. data/lib/gitlab_quality/test_tooling/gitlab_client/gitlab_client.rb +2 -9
  26. data/lib/gitlab_quality/test_tooling/gitlab_client/group_labels_client.rb +34 -0
  27. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_client.rb +1 -1
  28. data/lib/gitlab_quality/test_tooling/gitlab_client/issues_dry_client.rb +2 -2
  29. data/lib/gitlab_quality/test_tooling/report/concerns/results_reporter.rb +1 -1
  30. data/lib/gitlab_quality/test_tooling/report/failed_test_issue.rb +1 -1
  31. data/lib/gitlab_quality/test_tooling/report/flaky_test_issue.rb +2 -2
  32. data/lib/gitlab_quality/test_tooling/report/generate_test_session.rb +2 -2
  33. data/lib/gitlab_quality/test_tooling/report/group_issues/error_message_normalizer.rb +49 -0
  34. data/lib/gitlab_quality/test_tooling/report/group_issues/error_pattern_matcher.rb +36 -0
  35. data/lib/gitlab_quality/test_tooling/report/group_issues/failure_processor.rb +73 -0
  36. data/lib/gitlab_quality/test_tooling/report/group_issues/group_results_in_issues.rb +48 -0
  37. data/lib/gitlab_quality/test_tooling/report/group_issues/incident_checker.rb +61 -0
  38. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_base.rb +48 -0
  39. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_creator.rb +44 -0
  40. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_finder.rb +81 -0
  41. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_formatter.rb +83 -0
  42. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_manager.rb +33 -0
  43. data/lib/gitlab_quality/test_tooling/report/group_issues/issue_updater.rb +87 -0
  44. data/lib/gitlab_quality/test_tooling/report/health_problem_reporter.rb +6 -3
  45. data/lib/gitlab_quality/test_tooling/report/knapsack_report_issue.rb +1 -1
  46. data/lib/gitlab_quality/test_tooling/report/merge_request_slow_tests_report.rb +2 -6
  47. data/lib/gitlab_quality/test_tooling/report/relate_failure_issue.rb +176 -5
  48. data/lib/gitlab_quality/test_tooling/report/report_as_issue.rb +0 -1
  49. data/lib/gitlab_quality/test_tooling/report/slow_test_issue.rb +2 -1
  50. data/lib/gitlab_quality/test_tooling/runtime/env.rb +9 -4
  51. data/lib/gitlab_quality/test_tooling/slack/post_to_slack.rb +103 -3
  52. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/api_log_finder.rb +1 -1
  53. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/application_log_finder.rb +1 -1
  54. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/exception_log_finder.rb +1 -1
  55. data/lib/gitlab_quality/test_tooling/system_logs/finders/rails/graphql_log_finder.rb +1 -1
  56. data/lib/gitlab_quality/test_tooling/test_meta/test_meta_updater.rb +39 -11
  57. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/config.rb +115 -15
  58. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/formatter.rb +61 -36
  59. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/test_metrics.rb +125 -80
  60. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/utils.rb +95 -0
  61. data/lib/gitlab_quality/test_tooling/test_result/base_test_result.rb +6 -2
  62. data/lib/gitlab_quality/test_tooling/version.rb +1 -1
  63. data/lib/gitlab_quality/test_tooling.rb +3 -0
  64. metadata +82 -55
  65. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/log_test_metrics.rb +0 -117
  66. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/gcs_tools.rb +0 -49
  67. data/lib/gitlab_quality/test_tooling/test_metrics_exporter/support/influxdb_tools.rb +0 -33
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'yaml'
5
+
6
+ require_relative 'utils'
7
+
8
+ module GitlabQuality
9
+ module TestTooling
10
+ module CodeCoverage
11
+ class CategoryOwners
12
+ include Utils
13
+
14
+ SOURCE_URL = URI('https://gitlab.com/gitlab-com/www-gitlab-com/raw/master/data/stages.yml')
15
+ BASE_DELAY = 1 # seconds
16
+ MAX_RETRIES = 3
17
+
18
+ # @return [Hash] Category ownership hierarchy, section -> stage -> group -> [categories]
19
+ # @example Return value
20
+ # {
21
+ # "team_planning" => { # section
22
+ # "project_management" => { # stage
23
+ # "plan" => [ # group
24
+ # "dev", # category
25
+ # "service_desk" # category
26
+ # ],
27
+ # "product_planning" => [ # group
28
+ # "portfolio_management", # category
29
+ # ...
30
+ # ]
31
+ # }
32
+ # },
33
+ # ...
34
+ # }
35
+ attr_reader :hierarchy
36
+
37
+ def initialize
38
+ @categories_map = {}
39
+ @hierarchy = {}
40
+
41
+ yaml_file = fetch_yaml_file
42
+ yaml_content = YAML.load(yaml_file)
43
+ populate_ownership_hierarchy(yaml_content)
44
+ end
45
+
46
+ # @return [Array<Hash>] Flattened category ownership
47
+ # @example Return value
48
+ # [
49
+ # { category: "team_planning", group: "project_management", stage: "plan", section: "dev" },
50
+ # { category: "service_desk", group: "project_management", stage: "plan", section: "dev" },
51
+ # { category: "portfolio_management", group: "product_planning", stage: "plan", section: "dev" }
52
+ # ...
53
+ # ]
54
+ def as_db_table
55
+ hierarchy.each_with_object([]) do |(section, stages), flattened_hierarchy|
56
+ next unless stages
57
+
58
+ stages.each do |stage, groups|
59
+ next unless groups
60
+
61
+ groups.each do |group, categories|
62
+ Array(categories).each do |category|
63
+ flattened_hierarchy << {
64
+ category: category,
65
+ group: group,
66
+ stage: stage,
67
+ section: section
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ # @return [Hash] Mapping of categories to teams (i.e., groups, stages, sections)
76
+ # @example Return value
77
+ # {
78
+ # "team_planning" => { group: "project_management", stage: "plan", section: "dev" },
79
+ # "service_desk" => { group: "project_management", stage: "plan", section: "dev" },
80
+ # "portfolio_management" => { group: "product_planning", stage: "plan", section: "dev" },
81
+ # ...
82
+ # }
83
+ def categories_to_teams
84
+ populate_categories_map(hierarchy)
85
+ @categories_map
86
+ end
87
+
88
+ private
89
+
90
+ def fetch_yaml_file
91
+ retries = 0
92
+
93
+ begin
94
+ response = Net::HTTP.get_response(SOURCE_URL)
95
+ raise "Failed to fetch file: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
96
+
97
+ response.body
98
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, IOError, RuntimeError => e
99
+ retries += 1
100
+ raise "Failed to fetch YAML after #{MAX_RETRIES} retries: #{e.message}" if retries >= MAX_RETRIES
101
+
102
+ delay = exponential_delay_with_jitter(retries)
103
+ warn "Fetch attempt #{retries} failed: #{e.message}. Retrying in #{delay.round(2)}s..."
104
+ sleep(delay)
105
+ retry
106
+ end
107
+ end
108
+
109
+ def populate_ownership_hierarchy(data)
110
+ stages = data['stages'] || {}
111
+
112
+ stages.each do |stage, stage_data|
113
+ next unless stage_data.is_a?(Hash)
114
+
115
+ section = stage_data['section']
116
+ groups = stage_data['groups'] || {}
117
+ next unless section
118
+
119
+ groups.each { |group, group_data| add_hierarchy_entry(section, stage, group, group_data['categories']) }
120
+ end
121
+ end
122
+
123
+ def add_hierarchy_entry(section, stage, group, categories)
124
+ @hierarchy[section] ||= {}
125
+ @hierarchy[section][stage] ||= {}
126
+ @hierarchy[section][stage][group] = categories || []
127
+ end
128
+
129
+ def populate_categories_map(data, current_section = nil, current_stage = nil, current_group = nil)
130
+ case data
131
+ when Hash # Sections / Stages / Groups
132
+ data.each do |key, value|
133
+ if current_section.nil? # Sections
134
+ populate_categories_map(value, key, nil, nil)
135
+ elsif current_stage.nil? # Stages
136
+ populate_categories_map(value, current_section, key, nil)
137
+ elsif current_group.nil? # Groups
138
+ populate_categories_map(value, current_section, current_stage, key)
139
+ else # Categories
140
+ populate_categories_map(value, current_section, current_stage, current_group)
141
+ end
142
+ end
143
+ when Array # Categories array
144
+ data.each do |category|
145
+ next unless category.is_a?(String)
146
+
147
+ @categories_map[category] = {
148
+ section: current_section,
149
+ stage: current_stage,
150
+ group: current_group
151
+ }
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,62 @@
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 CategoryOwnersTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
10
+ TABLE_NAME = "category_owners"
11
+
12
+ # Creates the ClickHouse table, if it doesn't exist already
13
+ # @return [nil]
14
+ def create
15
+ logger.debug("#{LOG_PREFIX} Creating category_owners table if it doesn't exist ...")
16
+
17
+ client.query(<<~SQL)
18
+ CREATE TABLE IF NOT EXISTS #{table_name} (
19
+ timestamp DateTime64(6, 'UTC') DEFAULT now64(),
20
+ category String,
21
+ group String,
22
+ stage String,
23
+ section String,
24
+ INDEX idx_group group TYPE set(360) GRANULARITY 1,
25
+ INDEX idx_stage stage TYPE set(360) GRANULARITY 1,
26
+ INDEX idx_section section TYPE set(360) GRANULARITY 1
27
+ ) ENGINE = MergeTree()
28
+ ORDER BY (category, timestamp)
29
+ SETTINGS index_granularity = 8192;
30
+ SQL
31
+
32
+ logger.info("#{LOG_PREFIX} Category owners table created/verified successfully")
33
+ end
34
+
35
+ def truncate
36
+ logger.debug("#{LOG_PREFIX} Truncating table #{full_table_name} ...")
37
+
38
+ client.query("TRUNCATE TABLE #{full_table_name}")
39
+
40
+ logger.info("#{LOG_PREFIX} Successfully truncated table #{full_table_name}")
41
+ end
42
+
43
+ private
44
+
45
+ # @return [Boolean] True if the record is valid, false otherwise
46
+ def valid_record?(record)
47
+ required_fields = %i[category group stage section]
48
+
49
+ required_fields.each do |field|
50
+ if record[field].nil?
51
+ logger.warn("#{LOG_PREFIX} Skipping record with nil #{field}: #{record}")
52
+ return false
53
+ end
54
+ end
55
+
56
+ true
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require_relative 'table'
5
+
6
+ module GitlabQuality
7
+ module TestTooling
8
+ module CodeCoverage
9
+ module ClickHouse
10
+ class CoverageMetricsTable < GitlabQuality::TestTooling::CodeCoverage::ClickHouse::Table
11
+ TABLE_NAME = "coverage_metrics"
12
+
13
+ # Creates the ClickHouse table, if it doesn't exist already
14
+ # @return [nil]
15
+ def create
16
+ logger.debug("#{LOG_PREFIX} Creating coverage_metrics table if it doesn't exist ...")
17
+
18
+ client.query(<<~SQL)
19
+ CREATE TABLE IF NOT EXISTS #{table_name} (
20
+ timestamp DateTime64(6, 'UTC'),
21
+ file String,
22
+ coverage Float64,
23
+ category Nullable(String),
24
+ ci_project_id Nullable(UInt32),
25
+ ci_project_path Nullable(String),
26
+ ci_job_name Nullable(String),
27
+ ci_job_id Nullable(UInt64),
28
+ ci_pipeline_id Nullable(UInt64),
29
+ ci_merge_request_iid Nullable(UInt32),
30
+ ci_branch Nullable(String),
31
+ ci_target_branch Nullable(String)
32
+ ) ENGINE = MergeTree()
33
+ PARTITION BY toYYYYMM(timestamp)
34
+ ORDER BY (ci_project_path, timestamp, file, ci_pipeline_id)
35
+ SETTINGS index_granularity = 8192, allow_nullable_key = 1;
36
+ SQL
37
+
38
+ logger.info("#{LOG_PREFIX} Coverage metrics table created/verified successfully")
39
+ end
40
+
41
+ private
42
+
43
+ # @return [Boolean] True if the record is valid, false otherwise
44
+ def valid_record?(record)
45
+ if record[:file].nil?
46
+ logger.warn("#{LOG_PREFIX} Skipping record with nil file: #{record}")
47
+ return false
48
+ end
49
+
50
+ if record[:coverage].nil?
51
+ logger.warn("#{LOG_PREFIX} Skipping record with nil coverage: #{record}")
52
+ return false
53
+ end
54
+
55
+ if record[:coverage].nan?
56
+ logger.warn("#{LOG_PREFIX} Skipping record with NaN coverage: #{record}")
57
+ return false
58
+ end
59
+
60
+ true
61
+ end
62
+
63
+ # @return [Hash] Transformed coverage data including timestamp and CI metadata
64
+ def sanitized_data_record(record)
65
+ {
66
+ timestamp: time,
67
+ file: record[:file],
68
+ coverage: record[:coverage],
69
+ category: record[:category],
70
+ **ci_metadata
71
+ }
72
+ end
73
+
74
+ # @return [Time] Common timestamp for all coverage records
75
+ def time
76
+ @time ||= begin
77
+ ci_created_at = ENV.fetch('CI_PIPELINE_CREATED_AT', nil)
78
+ ci_created_at ? Time.strptime(ci_created_at, '%Y-%m-%dT%H:%M:%S%z') : Time.now.utc
79
+ end
80
+ end
81
+
82
+ # @return [Hash] CI-related metadata
83
+ def ci_metadata
84
+ {
85
+ ci_project_id: env_to_int('CI_PROJECT_ID'),
86
+ ci_project_path: ENV.fetch('CI_PROJECT_PATH', nil),
87
+ ci_job_name: ENV.fetch('CI_JOB_NAME', nil)&.gsub(%r{ \d{1,2}/\d{1,2}}, ''),
88
+ ci_job_id: env_to_int('CI_JOB_ID'),
89
+ ci_pipeline_id: env_to_int('CI_PIPELINE_ID'),
90
+ ci_merge_request_iid: env_to_int('CI_MERGE_REQUEST_IID') || env_to_int('TOP_UPSTREAM_MERGE_REQUEST_IID'),
91
+ ci_branch: ENV.fetch('CI_COMMIT_REF_NAME', nil),
92
+ ci_target_branch: ENV.fetch('CI_MERGE_REQUEST_TARGET_BRANCH_NAME', nil)
93
+ }
94
+ end
95
+
96
+ # @param name [String] Environment variable name
97
+ # @return [Integer, nil] Environment variable converted to integer or
98
+ # nil if not present or empty
99
+ def env_to_int(name)
100
+ value = ENV.fetch(name, nil)
101
+ return nil if value.nil? || value.empty?
102
+
103
+ value.to_i
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ module ClickHouse
7
+ class Table
8
+ LOG_PREFIX = "[CodeCoverage]"
9
+
10
+ def initialize(url:, database:, username: nil, password: nil, logger: nil)
11
+ @url = url
12
+ @database = database
13
+ @username = username
14
+ @password = password
15
+ @logger = logger || Runtime::Logger.logger
16
+ end
17
+
18
+ # @param data [Array<Hash>] Code coverage related data to be pushed to ClickHouse
19
+ # @return [nil]
20
+ def push(data) # rubocop:disable Metrics/AbcSize
21
+ return logger.warn("#{LOG_PREFIX} No data found, skipping ClickHouse export!") if data.empty?
22
+
23
+ logger.debug("#{LOG_PREFIX} Starting data export to ClickHouse")
24
+ sanitized_data = sanitize(data)
25
+
26
+ client.insert_json_data(table_name, sanitized_data)
27
+ logger.info("#{LOG_PREFIX} Successfully pushed #{sanitized_data.size} records to #{full_table_name}!")
28
+ rescue StandardError => e
29
+ logger.error("#{LOG_PREFIX} Error occurred while pushing data to #{full_table_name}: #{e.message}")
30
+ raise
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :url, :database, :username, :password, :logger
36
+
37
+ def sanitize(data)
38
+ data.filter_map { |record| sanitized_data_record(record) if valid_record?(record) }
39
+ end
40
+
41
+ def sanitized_data_record(record)
42
+ record
43
+ end
44
+
45
+ def full_table_name
46
+ "#{database}.#{table_name}"
47
+ end
48
+
49
+ def table_name
50
+ self.class::TABLE_NAME
51
+ rescue NameError
52
+ raise NotImplementedError, "#{self.class} must define the TABLE_NAME constant"
53
+ end
54
+
55
+ def valid_record?(_record)
56
+ raise NotImplementedError, "#{self.class}##{__method__} method must be implemented in a subclass"
57
+ end
58
+
59
+ # @return [GitlabQuality::TestTooling::ClickHouse::Client]
60
+ def client
61
+ @client ||= GitlabQuality::TestTooling::ClickHouse::Client.new(
62
+ url: url,
63
+ database: database,
64
+ username: username,
65
+ password: password,
66
+ logger: logger
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class CoverageData
7
+ # @param [Hash<String, Hash>] code_coverage_by_source_file Source file
8
+ # mapped to test coverage data
9
+ # @param [Hash<String, Array<String>>] source_file_to_tests Source files
10
+ # mapped to all test files testing them
11
+ # @param [Hash<String, Array<String>>] tests_to_categories Test files
12
+ # mapped to all feature categories they belong to
13
+ # @param [Hash<String, Hash>] categories_to_teams Mapping of categories
14
+ # to teams (i.e., groups, stages, sections)
15
+ def initialize(code_coverage_by_source_file, source_file_to_tests, tests_to_categories, categories_to_teams)
16
+ @code_coverage_by_source_file = code_coverage_by_source_file
17
+ @source_file_to_tests = source_file_to_tests
18
+ @tests_to_categories = tests_to_categories
19
+ @categories_to_teams = categories_to_teams
20
+ end
21
+
22
+ # @return [Array<Hash<Symbol, String>>] Mapping of column name to row
23
+ # value
24
+ # @example Return value
25
+ # [
26
+ # {
27
+ # file: "app/channels/application_cable/channel.rb"
28
+ # coverage: 100.0
29
+ # category: "team_planning"
30
+ # group: "project_management"
31
+ # stage: "plan"
32
+ # section: "dev"
33
+ # },
34
+ # ...
35
+ # ]
36
+ def as_db_table
37
+ all_files.flat_map do |file|
38
+ coverage = @code_coverage_by_source_file[file]&.dig(:percentage)
39
+ categories = categories_for(file)
40
+ if categories.empty?
41
+ { file: file, coverage: coverage }.merge(no_owner_info)
42
+ else
43
+ categories.map do |category|
44
+ { file: file, coverage: coverage }.merge(owner_info(category))
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def no_owner_info
53
+ {
54
+ category: nil,
55
+ group: nil,
56
+ stage: nil,
57
+ section: nil
58
+ }
59
+ end
60
+
61
+ def owner_info(category)
62
+ owner_info = @categories_to_teams[category]
63
+
64
+ {
65
+ category: category,
66
+ group: owner_info&.dig(:group),
67
+ stage: owner_info&.dig(:stage),
68
+ section: owner_info&.dig(:section)
69
+ }
70
+ end
71
+
72
+ def categories_for(file)
73
+ @source_file_to_tests[file]&.flat_map { |test_file| @tests_to_categories[test_file] || [] }&.uniq || []
74
+ end
75
+
76
+ def all_files
77
+ @all_files ||= @code_coverage_by_source_file.keys
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitlabQuality
4
+ module TestTooling
5
+ module CodeCoverage
6
+ class LcovFile
7
+ # @param [String] lcov_file_content The content of the lcov file
8
+ def initialize(lcov_file_content)
9
+ @lcov_file_content = lcov_file_content
10
+ end
11
+
12
+ # @return [Hash<String, Hash>] The parsed content of the lcov file
13
+ # @example Return value
14
+ # {
15
+ # "path/to/file1.rb" => {
16
+ # line_coverage: { 1 => 1, 2 => 0 },
17
+ # branch_coverage: {},
18
+ # total_lines: 2,
19
+ # covered_lines: 1,
20
+ # percentage: 50.0
21
+ # },
22
+ # ...
23
+ # }
24
+ def parsed_content
25
+ return @parsed_content if @parsed_content
26
+
27
+ @parsed_content = {}
28
+ @current_file = nil
29
+
30
+ @lcov_file_content.each_line do |line|
31
+ case line
32
+ when /^SF:(.+)$/
33
+ register_source_file(::Regexp.last_match(1))
34
+ when /^DA:(\d+),(\d+)$/
35
+ register_line_data(::Regexp.last_match(1), ::Regexp.last_match(2))
36
+ when /^BRDA:(\d+),(\d+),(\d+),(-|\d+)$/
37
+ register_branch_data(::Regexp.last_match(1), ::Regexp.last_match(4))
38
+ when /^end_of_record$/
39
+ @current_file = nil
40
+ end
41
+ end
42
+
43
+ include_coverage
44
+ @parsed_content
45
+ end
46
+
47
+ private
48
+
49
+ def include_coverage
50
+ @parsed_content.each_key do |file|
51
+ # TODO: Support branch coverage too
52
+ @parsed_content[file].merge!(line_coverage_for(file))
53
+ end
54
+ end
55
+
56
+ def line_coverage_for(file)
57
+ data = @parsed_content[file]
58
+ return unless data
59
+
60
+ total_lines = data[:line_coverage].size
61
+ covered_lines = data[:line_coverage].values.count(&:positive?)
62
+
63
+ {
64
+ total_lines: total_lines,
65
+ covered_lines: covered_lines,
66
+ percentage: total_lines.zero? ? 0.0 : (covered_lines.to_f / total_lines * 100).round(2)
67
+ }
68
+ end
69
+
70
+ def register_source_file(filename)
71
+ @current_file = filename.gsub(%r{^\./}, '')
72
+ @parsed_content[@current_file] = { line_coverage: {}, branch_coverage: {} }
73
+ end
74
+
75
+ def register_line_data(line_no, count)
76
+ return unless @current_file
77
+
78
+ @parsed_content[@current_file][:line_coverage][line_no.to_i] = count.to_i
79
+ end
80
+
81
+ def register_branch_data(line_no, taken)
82
+ return unless @current_file
83
+
84
+ taken_count = taken == '-' ? 0 : taken.to_i
85
+ @parsed_content[@current_file][:branch_coverage][line_no.to_i] ||= []
86
+ @parsed_content[@current_file][:branch_coverage][line_no.to_i] << taken_count
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module GitlabQuality
6
+ module TestTooling
7
+ module CodeCoverage
8
+ class RspecReport
9
+ # @param [Hash<String, Object>] rspec_report The content of an RSpec
10
+ # report
11
+ def initialize(rspec_report)
12
+ @rspec_report = rspec_report
13
+ end
14
+
15
+ # @return [Array<Hash<String, String>>] Content of the "examples"
16
+ # section of the RSpec report
17
+ def examples
18
+ @examples ||= @rspec_report['examples']
19
+ end
20
+
21
+ # @return [Hash<String, Array<String>>] Test files mapped to all feature
22
+ # categories they belong to
23
+ # @example Return value
24
+ # {
25
+ # "spec/path/to/file_spec.rb" => [
26
+ # "feature_category1", "feature_category2"
27
+ # ],
28
+ # ...
29
+ # }
30
+ def tests_to_categories
31
+ @tests_to_categories ||= examples.to_a.filter_map do |example|
32
+ next unless example.is_a?(Hash)
33
+
34
+ file_path = example['file_path']
35
+ next unless file_path.is_a?(String)
36
+
37
+ [file_path.gsub('./', ''), Array(example['feature_category']).compact]
38
+ end.to_h
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end