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.
- checksums.yaml +4 -4
- data/README.md +181 -12
- data/exe/coverage-reporter +8 -0
- data/lib/coverage_reporter/cli.rb +44 -0
- data/lib/coverage_reporter/coverage_analyzer.rb +153 -0
- data/lib/coverage_reporter/coverage_collator.rb +36 -0
- data/lib/coverage_reporter/coverage_report_loader.rb +42 -0
- data/lib/coverage_reporter/global_comment.rb +60 -0
- data/lib/coverage_reporter/global_comment_poster.rb +29 -0
- data/lib/coverage_reporter/inline_comment.rb +22 -0
- data/lib/coverage_reporter/inline_comment_factory.rb +46 -0
- data/lib/coverage_reporter/inline_comment_poster.rb +91 -0
- data/lib/coverage_reporter/modified_ranges_extractor.rb +106 -0
- data/lib/coverage_reporter/options/base.rb +20 -0
- data/lib/coverage_reporter/options/collate.rb +33 -0
- data/lib/coverage_reporter/options/report.rb +87 -0
- data/lib/coverage_reporter/pull_request.rb +100 -0
- data/lib/coverage_reporter/runner.rb +39 -0
- data/lib/coverage_reporter/simple_cov/patches/result_hash_formatter_patch.rb +26 -0
- data/lib/coverage_reporter/uncovered_ranges_extractor.rb +68 -0
- data/lib/coverage_reporter/version.rb +5 -0
- data/lib/coverage_reporter.rb +52 -0
- data/lib/tasks/coverage.rake +25 -0
- metadata +68 -6
- data/lib/coverage/reporter/version.rb +0 -7
- data/lib/coverage/reporter.rb +0 -7
@@ -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,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
|