coverage-reporter 0.1.0 → 0.3.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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module CoverageReporter
6
+ class InlineCommentPoster
7
+ def initialize(pull_request:, commit_sha:, inline_comments:)
8
+ @pull_request = pull_request
9
+ @commit_sha = commit_sha
10
+ @updated_comment_ids = Set.new
11
+ @inline_comments = inline_comments
12
+ @existing_coverage_comments = {}
13
+ end
14
+
15
+ def call
16
+ retrieve_existing_coverage_comments
17
+
18
+ inline_comments.each do |comment|
19
+ logger.info("Posting inline comment for #{comment.path}: #{comment.start_line}–#{comment.line}")
20
+ post_comment(comment)
21
+ end
22
+
23
+ cleanup_stale_comments
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :pull_request, :commit_sha, :updated_comment_ids, :inline_comments, :existing_coverage_comments
29
+
30
+ def logger
31
+ CoverageReporter.logger
32
+ end
33
+
34
+ def retrieve_existing_coverage_comments
35
+ logger.debug("Recording existing coverage comments")
36
+ @existing_coverage_comments = pull_request
37
+ .inline_comments
38
+ .filter { |comment| coverage_comment?(comment) }
39
+ .to_h do |comment|
40
+ logger.debug("Found existing coverage comment: #{comment.id} for #{comment.path}:#{comment.start_line}-#{comment.line}")
41
+ [comment.id, comment]
42
+ end
43
+ end
44
+
45
+ def cleanup_stale_comments
46
+ comment_ids_to_delete = existing_coverage_comments.keys - updated_comment_ids.to_a
47
+
48
+ if comment_ids_to_delete.any?
49
+ logger.debug("Cleaning up #{comment_ids_to_delete.size} unused coverage comments")
50
+
51
+ comment_ids_to_delete.each do |comment_id|
52
+ logger.info("Deleting stale coverage comment: #{comment_id}")
53
+ pull_request.delete_inline_comment(comment_id)
54
+ end
55
+ else
56
+ logger.debug("No stale coverage comments to clean up")
57
+ end
58
+ end
59
+
60
+ def coverage_comment?(comment)
61
+ comment.body&.include?(INLINE_COMMENT_MARKER)
62
+ end
63
+
64
+ def post_comment(comment)
65
+ existing_comment = existing_comment_for_path_and_lines(comment.path, comment.start_line, comment.line)
66
+
67
+ if existing_comment
68
+ pull_request.update_inline_comment(id: existing_comment.id, body: comment.body)
69
+ updated_comment_ids.add(existing_comment.id)
70
+ else
71
+ pull_request.add_comment_on_lines(
72
+ commit_id: commit_sha,
73
+ path: comment.path,
74
+ start_line: comment.start_line,
75
+ line: comment.line,
76
+ body: comment.body
77
+ )
78
+ end
79
+ end
80
+
81
+ def existing_comment_for_path_and_lines(path, start_line, line)
82
+ existing_coverage_comments.values.find do |comment|
83
+ if line == start_line
84
+ comment.path == path && comment.line == line
85
+ else
86
+ comment.path == path && comment.start_line == start_line && comment.line == line
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ class ModifiedRangesExtractor
5
+ HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
6
+
7
+ def initialize(diff_text)
8
+ @diff_text = diff_text
9
+ end
10
+
11
+ def call
12
+ return {} unless @diff_text
13
+
14
+ parse_diff(@diff_text)
15
+ rescue StandardError
16
+ {}
17
+ end
18
+
19
+ private
20
+
21
+ def parse_diff(text)
22
+ changed = Hash.new { |h, k| h[k] = [] }
23
+ current_file = nil
24
+ current_new_line = nil
25
+
26
+ text.each_line do |raw_line|
27
+ line = raw_line.chomp
28
+
29
+ if file_header_line?(line)
30
+ current_file = parse_new_file_path(line)
31
+ next
32
+ end
33
+
34
+ if (m = hunk_header_match(line))
35
+ current_new_line = m[1].to_i
36
+ next
37
+ end
38
+
39
+ next unless current_file && current_new_line
40
+
41
+ current_new_line = process_content_line(line, changed, current_file, current_new_line)
42
+ end
43
+
44
+ # Convert arrays of line numbers to ranges
45
+ changed.transform_values { |arr| consolidate_to_ranges(arr.uniq.sort) }
46
+ end
47
+
48
+ def file_header_line?(line)
49
+ line.start_with?("+++ ")
50
+ end
51
+
52
+ def hunk_header_match(line)
53
+ HUNK_HEADER.match(line)
54
+ end
55
+
56
+ def process_content_line(line, changed, current_file, current_new_line)
57
+ case line[0]
58
+ when "+"
59
+ handle_added_line(line, changed, current_file, current_new_line)
60
+ when "-"
61
+ current_new_line
62
+ when " "
63
+ current_new_line + 1
64
+ end
65
+ end
66
+
67
+ def handle_added_line(line, changed, current_file, current_new_line)
68
+ return current_new_line if line.start_with?("+++ ")
69
+
70
+ changed[current_file] << current_new_line
71
+ current_new_line + 1
72
+ end
73
+
74
+ def parse_new_file_path(line)
75
+ return nil if line.end_with?(File::NULL)
76
+
77
+ if (m = line.match(%r{\A\+\+\+\s[wb]/(.+)\z}))
78
+ m[1]
79
+ end
80
+ end
81
+
82
+ def consolidate_to_ranges(line_numbers)
83
+ return [] if line_numbers.empty?
84
+
85
+ ranges = []
86
+ start = line_numbers.first
87
+ last = line_numbers.first
88
+
89
+ line_numbers.each_cons(2) do |current, next_line|
90
+ if next_line == current + 1
91
+ # Consecutive line, extend current range
92
+ last = next_line
93
+ else
94
+ # Gap found, close current range and start new one
95
+ ranges << [start, last]
96
+ start = next_line
97
+ last = next_line
98
+ end
99
+ end
100
+
101
+ # Add the final range
102
+ ranges << [start, last]
103
+ ranges
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ module Options
5
+ # Interface class that defines the contract for option classes
6
+ class Base
7
+ # Returns a hash of default options
8
+ def self.defaults
9
+ raise NotImplementedError, "Subclasses must implement #{__method__}"
10
+ end
11
+
12
+ # Parses command line arguments and returns a hash of options
13
+ # @param argv [Array<String>] Command line arguments
14
+ # @return [Hash] Parsed options
15
+ def self.parse(argv)
16
+ raise NotImplementedError, "Subclasses must implement #{__method__}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module CoverageReporter
6
+ module Options
7
+ class Collate < Base
8
+ def self.defaults
9
+ {
10
+ coverage_dir: "coverage"
11
+ }
12
+ end
13
+
14
+ def self.parse(argv)
15
+ opts = defaults.dup
16
+
17
+ parser = OptionParser.new do |o|
18
+ o.banner = "Usage: coverage-reporter collate [options]"
19
+ o.on("--coverage-dir DIR", "Directory containing coverage files (default: coverage)") do |v|
20
+ opts[:coverage_dir] = v
21
+ end
22
+ o.on_tail("-h", "--help", "Show help") do
23
+ puts o
24
+ exit 0
25
+ end
26
+ end
27
+
28
+ parser.parse!(argv)
29
+ opts
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module CoverageReporter
6
+ module Options
7
+ class Report < Base
8
+ def self.defaults
9
+ {
10
+ commit_sha: ENV.fetch("COMMIT_SHA", nil),
11
+ coverage_report_path: ENV.fetch("COVERAGE_REPORT_PATH", "coverage/coverage.json"),
12
+ github_token: ENV.fetch("GITHUB_TOKEN", nil),
13
+ pr_number: ENV.fetch("PR_NUMBER", nil),
14
+ repo: normalize_repo(ENV.fetch("REPO", nil)),
15
+ report_url: ENV.fetch("REPORT_URL", nil)
16
+ }
17
+ end
18
+
19
+ # rubocop:disable Metrics/AbcSize
20
+ # rubocop:disable Metrics/MethodLength
21
+ def self.parse(argv)
22
+ opts = defaults.dup
23
+
24
+ parser = OptionParser.new do |o|
25
+ o.banner = "Usage: coverage-reporter [options]"
26
+ o.on("--report-url URL", "Report URL used for links (default: $REPORT_URL)") do |v|
27
+ opts[:report_url] = v
28
+ end
29
+ o.on("--commit-sha SHA", "GitHub commit SHA (default: $COMMIT_SHA)") do |v|
30
+ opts[:commit_sha] = v
31
+ end
32
+ o.on(
33
+ "--coverage-report-path PATH",
34
+ "Path to merged SimpleCov coverage.json (default: coverage/coverage.json)"
35
+ ) do |v|
36
+ opts[:coverage_report_path] = v
37
+ end
38
+ o.on("--github-token TOKEN", "GitHub token (default: $GITHUB_TOKEN)") { |v| opts[:github_token] = v }
39
+ o.on("--pr-number NUMBER", "GitHub pull request number (default: $PR_NUMBER)") do |v|
40
+ opts[:pr_number] = v
41
+ end
42
+ o.on("--repo REPO", "GitHub repository (default: $REPO)") do |v|
43
+ opts[:repo] = normalize_repo(v)
44
+ end
45
+ o.on_tail("-h", "--help", "Show help") do
46
+ puts o
47
+ exit 0
48
+ end
49
+ end
50
+ # rubocop:enable Metrics/AbcSize
51
+ # rubocop:enable Metrics/MethodLength
52
+ parser.parse!(argv)
53
+
54
+ validate!(opts)
55
+ opts
56
+ end
57
+
58
+ def self.validate!(opts)
59
+ missing = collect_missing_options(opts)
60
+ return if missing.empty?
61
+
62
+ abort "coverage-reporter: missing required option(s): #{missing.join(', ')}"
63
+ end
64
+
65
+ def self.collect_missing_options(opts)
66
+ required_options = {
67
+ github_token: "--github-token or $GITHUB_TOKEN",
68
+ repo: "--repo or $REPO",
69
+ pr_number: "--pr-number or $PR_NUMBER",
70
+ commit_sha: "--commit-sha or $COMMIT_SHA"
71
+ }
72
+
73
+ required_options.filter_map do |key, message|
74
+ message if opts[key].to_s.strip.empty?
75
+ end
76
+ end
77
+
78
+ def self.normalize_repo(repo)
79
+ return repo if repo.nil? || repo.strip.empty?
80
+
81
+ repo.strip
82
+ .gsub(%r{^(https://github\.com/|git@github\.com:)}, "")
83
+ .gsub(/\.git$/, "")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ class PullRequest
5
+ def initialize(github_token:, repo:, pr_number:)
6
+ opts = { access_token: github_token }
7
+
8
+ @client = ::Octokit::Client.new(**opts)
9
+ @client.auto_paginate = true
10
+ @repo = repo
11
+ @pr_number = pr_number
12
+ end
13
+
14
+ def latest_commit_sha
15
+ @latest_commit_sha ||= client.pull_request(repo, pr_number).head.sha
16
+ end
17
+
18
+ # get global comments
19
+ def global_comments
20
+ client.issue_comments(repo, pr_number)
21
+ end
22
+
23
+ # add global comment
24
+ def add_global_comment(body:)
25
+ client.add_comment(repo, pr_number, body)
26
+ end
27
+
28
+ # update global comment
29
+ def update_global_comment(id:, body:)
30
+ client.update_comment(repo, id, body)
31
+ end
32
+
33
+ # delete global comment
34
+ def delete_global_comment(id)
35
+ client.delete_comment(repo, id)
36
+ end
37
+
38
+ # get inline comments
39
+ def inline_comments
40
+ client.pull_request_comments(repo, pr_number)
41
+ end
42
+
43
+ # update inline comment
44
+ def update_inline_comment(id:, body:)
45
+ client.update_pull_request_comment(repo, id, body)
46
+ end
47
+
48
+ # delete inline comment
49
+ def delete_inline_comment(id)
50
+ client.delete_pull_request_comment(repo, id)
51
+ end
52
+
53
+ def add_comment_on_lines(commit_id:, path:, start_line:, line:, body:)
54
+ payload = build_comment_payload(body:, commit_id:, path:, start_line:, line:)
55
+ create_comment_with_error_handling(payload)
56
+ end
57
+
58
+ def diff
59
+ @diff ||= client.pull_request(repo, pr_number, accept: "application/vnd.github.v3.diff")
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :client, :repo, :pr_number
65
+
66
+ def logger
67
+ CoverageReporter.logger
68
+ end
69
+
70
+ def build_comment_payload(body:, commit_id:, path:, start_line:, line:)
71
+ payload = {
72
+ body: body,
73
+ commit_id: commit_id,
74
+ path: path,
75
+ side: "RIGHT"
76
+ }
77
+
78
+ if start_line && line > start_line
79
+ payload[:line] = line
80
+ payload[:start_line] = start_line
81
+ elsif start_line == line
82
+ payload[:line] = line
83
+ end
84
+
85
+ payload
86
+ end
87
+
88
+ def create_comment_with_error_handling(payload)
89
+ client.post("/repos/#{repo}/pulls/#{pr_number}/comments", payload)
90
+ rescue Octokit::Error => e
91
+ handle_github_api_error(e, payload)
92
+ end
93
+
94
+ def handle_github_api_error(error, payload)
95
+ logger.error("GitHub API Error: #{error.message}")
96
+ logger.error("Payload: #{payload.inspect}")
97
+ raise
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ class Runner
5
+ def initialize(options)
6
+ @commit_sha = options[:commit_sha]
7
+ @coverage_report_path = options[:coverage_report_path]
8
+ @github_token = options[:github_token]
9
+ @report_url = options[:report_url]
10
+ @repo = options[:repo]
11
+ @pr_number = options[:pr_number]
12
+ end
13
+
14
+ # rubocop:disable Metrics/AbcSize
15
+ def run
16
+ pull_request = PullRequest.new(github_token:, repo:, pr_number:)
17
+ coverage_report = CoverageReportLoader.new(coverage_report_path).call
18
+ modified_ranges = ModifiedRangesExtractor.new(pull_request.diff).call
19
+ uncovered_ranges = UncoveredRangesExtractor.new(coverage_report).call
20
+ analysis_result = CoverageAnalyzer.new(uncovered_ranges:, modified_ranges:).call
21
+ intersection = analysis_result[:intersections]
22
+ coverage_stats = analysis_result[:coverage_stats]
23
+ inline_comments = InlineCommentFactory.new(intersection:, commit_sha:).call
24
+ InlineCommentPoster.new(pull_request:, commit_sha:, inline_comments:).call
25
+ global_comment = GlobalComment.new(
26
+ commit_sha:,
27
+ report_url:,
28
+ coverage_percentage: coverage_stats[:coverage_percentage],
29
+ intersections: intersection
30
+ )
31
+ GlobalCommentPoster.new(pull_request:, global_comment:).call
32
+ end
33
+ # rubocop:enable Metrics/AbcSize
34
+
35
+ private
36
+
37
+ attr_reader :coverage_report_path, :github_token, :report_url, :repo, :pr_number, :commit_sha
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "simplecov_json_formatter"
4
+
5
+ module CoverageReporter
6
+ module SimpleCov
7
+ module Patches
8
+ module ResultHashFormatterPatch
9
+ def self.included(base)
10
+ base.class_eval do
11
+ private
12
+
13
+ def format_files
14
+ @result.files.each do |source_file|
15
+ # Use project_filename instead of filename to get the relative path
16
+ formatted_result[:coverage][source_file.project_filename] = format_source_file(source_file)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ SimpleCovJSONFormatter::ResultHashFormatter.include(CoverageReporter::SimpleCov::Patches::ResultHashFormatterPatch)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ class UncoveredRangesExtractor
5
+ def initialize(coverage_report)
6
+ @coverage_report = coverage_report
7
+ end
8
+
9
+ def call
10
+ coverage_map = Hash.new { |h, k| h[k] = [] }
11
+
12
+ return coverage_map unless coverage
13
+
14
+ coverage.each do |filename, data|
15
+ # Remove leading slash from file paths for consistency
16
+ normalized_filename = filename.start_with?("/") ? filename[1..] : filename
17
+ uncovered_ranges = extract_uncovered_ranges(data["lines"])
18
+ coverage_map[normalized_filename] = uncovered_ranges
19
+ end
20
+
21
+ coverage_map
22
+ end
23
+
24
+ private
25
+
26
+ def coverage
27
+ return nil unless @coverage_report.is_a?(Hash)
28
+
29
+ @coverage_report["coverage"]
30
+ end
31
+
32
+ def extract_uncovered_ranges(lines)
33
+ return [] unless lines.is_a?(Array)
34
+
35
+ uncovered_lines = []
36
+ lines.each_with_index do |count, idx|
37
+ # Only lines with 0 count are considered uncovered
38
+ # null values are not relevant for coverage
39
+ uncovered_lines << (idx + 1) if count == 0
40
+ end
41
+ convert_to_ranges(uncovered_lines)
42
+ end
43
+
44
+ def convert_to_ranges(lines)
45
+ return [] if lines.empty?
46
+
47
+ ranges = []
48
+ start_line = lines.first
49
+ end_line = lines.first
50
+
51
+ lines.each_cons(2) do |current, next_line|
52
+ if next_line == current + 1
53
+ # Consecutive lines, extend the range
54
+ end_line = next_line
55
+ else
56
+ # Gap found, close current range and start new one
57
+ ranges << [start_line, end_line]
58
+ start_line = next_line
59
+ end_line = next_line
60
+ end
61
+ end
62
+
63
+ # Add the last range
64
+ ranges << [start_line, end_line]
65
+ ranges
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "octokit"
5
+
6
+ module CoverageReporter
7
+ # Comment markers for identifying coverage-related comments
8
+ INLINE_COMMENT_MARKER = "<!-- coverage-inline-marker -->"
9
+ GLOBAL_COMMENT_MARKER = "<!-- coverage-comment-marker -->"
10
+
11
+ autoload :CLI, "coverage_reporter/cli"
12
+ autoload :VERSION, "coverage_reporter/version"
13
+
14
+ class << self
15
+ attr_writer :logger
16
+
17
+ def logger
18
+ @logger ||= Logger.new($stdout).tap do |log|
19
+ log.progname = name
20
+ log.level = valid_log_level(ENV.fetch("COVERAGE_REPORTER_LOG_LEVEL", nil))
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def valid_log_level(env_level)
27
+ return "INFO" if env_level.nil? || env_level.empty?
28
+
29
+ level = env_level.upcase
30
+ valid_levels = %w[DEBUG INFO WARN ERROR]
31
+
32
+ valid_levels.include?(level) ? level : "INFO"
33
+ end
34
+ end
35
+
36
+ require_relative "coverage_reporter/coverage_analyzer"
37
+ require_relative "coverage_reporter/coverage_collator"
38
+ require_relative "coverage_reporter/coverage_report_loader"
39
+ require_relative "coverage_reporter/global_comment"
40
+ require_relative "coverage_reporter/global_comment_poster"
41
+ require_relative "coverage_reporter/inline_comment"
42
+ require_relative "coverage_reporter/inline_comment_factory"
43
+ require_relative "coverage_reporter/inline_comment_poster"
44
+ require_relative "coverage_reporter/modified_ranges_extractor"
45
+ require_relative "coverage_reporter/options/base"
46
+ require_relative "coverage_reporter/options/collate"
47
+ require_relative "coverage_reporter/options/report"
48
+ require_relative "coverage_reporter/pull_request"
49
+ require_relative "coverage_reporter/runner"
50
+ require_relative "coverage_reporter/uncovered_ranges_extractor"
51
+ require_relative "coverage_reporter/simple_cov/patches/result_hash_formatter_patch"
52
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :coverage do
4
+ desc "Merge coverage reports"
5
+ task :collate do
6
+ require "simplecov"
7
+ require "simplecov_json_formatter"
8
+ require "coverage_reporter/simple_cov/patches/result_hash_formatter_patch"
9
+
10
+ # Collate JSON coverage reports and generate both HTML and JSON outputs
11
+ files = Dir["coverage/resultset-*.json"]
12
+ abort "No coverage JSON files found to collate" if files.empty?
13
+ puts "Collate coverage files: #{files.join(', ')}"
14
+ SimpleCov.collate(files) do
15
+ formatter SimpleCov::Formatter::MultiFormatter.new(
16
+ [
17
+ SimpleCov::Formatter::HTMLFormatter,
18
+ SimpleCov::Formatter::JSONFormatter
19
+ ]
20
+ )
21
+ end
22
+
23
+ puts "✅ Coverage merged and report generated."
24
+ end
25
+ end