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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 13b5ede2585f67e28da56541940a417c9a2266f334a836d8ba4aae949f35a7aa
|
4
|
+
data.tar.gz: a23afcc6d059da70d2b0857e9a06af23177b0058690d66695596f7b7e0f774df
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 29ba80d86581dd7f99b7141cec2e3a9a1fd81ae41dcff5d82d9305556b2a63489121946a9279cb92e905b24b983352ceb690c50ef1946f82ba7a95a2201dd25e
|
7
|
+
data.tar.gz: 592e4e220fea3f616aa7ca89938638fa463bb3a7a1ab5a3dce42ce4c0c9923baf44cba5fb2e7bfda525d5e30e524903a42c17296b0c44a547b9e02e611925cd7
|
data/README.md
CHANGED
@@ -3,35 +3,204 @@
|
|
3
3
|
[](https://rubygems.org/gems/coverage-reporter)
|
4
4
|
[](https://www.ruby-toolbox.com/projects/coverage-reporter)
|
5
5
|
[](https://github.com/gabrieltaylor/coverage-reporter/actions/workflows/ci.yml)
|
6
|
-
[](https://codeclimate.com/github/gabrieltaylor/coverage-reporter)
|
7
6
|
|
8
|
-
Report code coverage from SimpleCov coverage reports to a
|
7
|
+
Report code coverage from SimpleCov coverage reports to a GitHub pull request. This tool analyzes your test coverage data and posts detailed comments on pull requests, highlighting uncovered lines in modified code.
|
9
8
|
|
10
9
|
---
|
11
10
|
|
12
|
-
- [
|
11
|
+
- [Installation](#installation)
|
12
|
+
- [Quick Start](#quick-start)
|
13
|
+
- [Configuration](#configuration)
|
14
|
+
- [Usage Examples](#usage-examples)
|
15
|
+
- [CI/CD Integration](#cicd-integration)
|
16
|
+
- [Command Line Options](#command-line-options)
|
17
|
+
- [Environment Variables](#environment-variables)
|
18
|
+
- [How It Works](#how-it-works)
|
13
19
|
- [License](#license)
|
14
|
-
- [Code of
|
15
|
-
- [
|
20
|
+
- [Code of Conduct](#code-of-conduct)
|
21
|
+
- [Contributing](#contributing)
|
16
22
|
|
17
|
-
##
|
23
|
+
## Installation
|
18
24
|
|
25
|
+
Add this line to your application's Gemfile:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
gem 'coverage-reporter'
|
29
|
+
```
|
30
|
+
|
31
|
+
And then execute:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
bundle install
|
19
35
|
```
|
36
|
+
|
37
|
+
Or install it directly:
|
38
|
+
|
39
|
+
```bash
|
20
40
|
gem install coverage-reporter
|
21
41
|
```
|
22
42
|
|
23
|
-
|
24
|
-
|
43
|
+
## Quick Start
|
44
|
+
|
45
|
+
1. **Generate a SimpleCov coverage report** in your test suite:
|
46
|
+
```ruby
|
47
|
+
# In your test helper or spec_helper
|
48
|
+
require 'simplecov'
|
49
|
+
SimpleCov.start
|
50
|
+
```
|
51
|
+
|
52
|
+
2. **Set up environment variables**:
|
53
|
+
```bash
|
54
|
+
export GITHUB_TOKEN="your_github_token_here"
|
55
|
+
export REPO="owner/repository"
|
56
|
+
export PR_NUMBER="123"
|
57
|
+
export COMMIT_SHA="abc123def456"
|
58
|
+
```
|
59
|
+
|
60
|
+
3. **Run the coverage reporter**:
|
61
|
+
```bash
|
62
|
+
coverage-reporter report
|
63
|
+
```
|
64
|
+
|
65
|
+
The tool will automatically:
|
66
|
+
- Load your coverage data from `coverage/coverage.json`
|
67
|
+
- Fetch the pull request diff from GitHub
|
68
|
+
- Identify uncovered lines in modified code
|
69
|
+
- Post inline comments on uncovered lines
|
70
|
+
- Add a global coverage summary comment
|
71
|
+
|
72
|
+
## Configuration
|
73
|
+
|
74
|
+
### Required Settings
|
75
|
+
|
76
|
+
- **GitHub Token**: A personal access token with `repo` permissions
|
77
|
+
- **Repository**: GitHub repository in `owner/repo` format
|
78
|
+
- **Pull Request Number**: The PR number to comment on
|
79
|
+
- **Commit SHA**: The commit SHA being analyzed
|
80
|
+
|
81
|
+
### Optional Settings
|
82
|
+
|
83
|
+
- **Coverage Report Path**: Path to your SimpleCov coverage.json file (default: `coverage/coverage.json`)
|
84
|
+
- **Build URL**: CI build URL for linking back to your build (default: `$BUILD_URL`)
|
85
|
+
|
86
|
+
## Usage Examples
|
87
|
+
|
88
|
+
### Basic Usage
|
89
|
+
|
90
|
+
```bash
|
91
|
+
coverage-reporter \
|
92
|
+
--github-token "$GITHUB_TOKEN" \
|
93
|
+
--repo "myorg/myrepo" \
|
94
|
+
--pr-number "42" \
|
95
|
+
--commit-sha "$GITHUB_SHA"
|
96
|
+
```
|
97
|
+
|
98
|
+
### Custom Coverage Report Path
|
99
|
+
|
100
|
+
```bash
|
101
|
+
coverage-reporter \
|
102
|
+
--github-token "$GITHUB_TOKEN" \
|
103
|
+
--repo "myorg/myrepo" \
|
104
|
+
--pr-number "42" \
|
105
|
+
--commit-sha "$GITHUB_SHA" \
|
106
|
+
--coverage-report-path "test/coverage/coverage.json"
|
25
107
|
```
|
26
108
|
|
109
|
+
### With Build URL
|
110
|
+
|
111
|
+
```bash
|
112
|
+
coverage-reporter \
|
113
|
+
--github-token "$GITHUB_TOKEN" \
|
114
|
+
--repo "myorg/myrepo" \
|
115
|
+
--pr-number "42" \
|
116
|
+
--commit-sha "$GITHUB_SHA" \
|
117
|
+
--build-url "https://github.com/myorg/myrepo/actions/runs/123456"
|
118
|
+
```
|
119
|
+
|
120
|
+
## CI/CD Integration
|
121
|
+
|
122
|
+
### GitHub Actions
|
123
|
+
|
124
|
+
```yaml
|
125
|
+
name: Test Coverage
|
126
|
+
on: [pull_request]
|
127
|
+
|
128
|
+
jobs:
|
129
|
+
test:
|
130
|
+
runs-on: ubuntu-latest
|
131
|
+
steps:
|
132
|
+
- uses: actions/checkout@v4
|
133
|
+
|
134
|
+
- name: Set up Ruby
|
135
|
+
uses: ruby/setup-ruby@v1
|
136
|
+
with:
|
137
|
+
ruby-version: 3.1
|
138
|
+
bundler-cache: true
|
139
|
+
|
140
|
+
- name: Run tests with coverage
|
141
|
+
run: bundle exec rspec
|
142
|
+
env:
|
143
|
+
COVERAGE: true
|
144
|
+
|
145
|
+
- name: Report coverage
|
146
|
+
run: bundle exec coverage-reporter
|
147
|
+
env:
|
148
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
149
|
+
REPO: ${{ github.repository }}
|
150
|
+
PR_NUMBER: ${{ github.event.number }}
|
151
|
+
COMMIT_SHA: ${{ github.sha }}
|
152
|
+
BUILD_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
153
|
+
```
|
154
|
+
|
155
|
+
## Command Line Options
|
156
|
+
|
157
|
+
| Option | Description | Default | Environment Variable |
|
158
|
+
|--------|-------------|---------|---------------------|
|
159
|
+
| `--github-token TOKEN` | GitHub personal access token | `$GITHUB_TOKEN` | `GITHUB_TOKEN` |
|
160
|
+
| `--repo REPO` | GitHub repository (owner/repo) | `$REPO` | `REPO` |
|
161
|
+
| `--pr-number NUMBER` | Pull request number | `$PR_NUMBER` | `PR_NUMBER` |
|
162
|
+
| `--commit-sha SHA` | Git commit SHA | `$COMMIT_SHA` | `COMMIT_SHA` |
|
163
|
+
| `--coverage-report-path PATH` | Path to coverage.json | `coverage/coverage.json` | `COVERAGE_REPORT_PATH` |
|
164
|
+
| `--build-url URL` | CI build URL for links | `$BUILD_URL` | `BUILD_URL` |
|
165
|
+
| `--help` | Show help message | - | - |
|
166
|
+
|
167
|
+
## Environment Variables
|
168
|
+
|
169
|
+
All command-line options can be set via environment variables:
|
170
|
+
|
171
|
+
```bash
|
172
|
+
export GITHUB_TOKEN="ghp_xxxxxxxxxxxx"
|
173
|
+
export REPO="myorg/myrepo"
|
174
|
+
export PR_NUMBER="123"
|
175
|
+
export COMMIT_SHA="abc123def456"
|
176
|
+
export COVERAGE_REPORT_PATH="coverage/coverage.json"
|
177
|
+
export BUILD_URL="https://ci.example.com/build/123"
|
178
|
+
```
|
179
|
+
|
180
|
+
## How It Works
|
181
|
+
|
182
|
+
1. **Loads Coverage Data**: Reads SimpleCov's `coverage.json` file to understand which lines are covered by tests
|
183
|
+
2. **Fetches PR Diff**: Retrieves the pull request diff from GitHub to identify modified lines
|
184
|
+
3. **Finds Intersections**: Identifies uncovered lines that were modified in the PR
|
185
|
+
4. **Posts Inline Comments**: Adds comments directly on uncovered lines in the diff
|
186
|
+
5. **Creates Summary**: Posts a global comment with overall coverage statistics
|
187
|
+
|
188
|
+
### GitHub Token Permissions
|
189
|
+
|
190
|
+
Your GitHub token needs the following permissions:
|
191
|
+
- `repo` (Full control of private repositories)
|
192
|
+
- `public_repo` (Access public repositories)
|
193
|
+
|
194
|
+
Create a token at: https://github.com/settings/tokens
|
195
|
+
|
27
196
|
## License
|
28
197
|
|
29
198
|
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
30
199
|
|
31
|
-
## Code of
|
200
|
+
## Code of Conduct
|
32
201
|
|
33
|
-
Everyone interacting in this project
|
202
|
+
Everyone interacting in this project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|
34
203
|
|
35
|
-
##
|
204
|
+
## Contributing
|
36
205
|
|
37
|
-
Pull requests are welcome!
|
206
|
+
Pull requests are welcome! Please read our [contribution guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
|
5
|
+
module CoverageReporter
|
6
|
+
class CLI
|
7
|
+
def self.start(argv)
|
8
|
+
case argv.first
|
9
|
+
when nil
|
10
|
+
show_usage_and_exit
|
11
|
+
when "report"
|
12
|
+
# Report command
|
13
|
+
options = Options::Report.parse(argv[1..])
|
14
|
+
Runner.new(options).run
|
15
|
+
when "collate"
|
16
|
+
collate_options = Options::Collate.parse(argv[1..])
|
17
|
+
CoverageCollator.new(collate_options).call
|
18
|
+
else
|
19
|
+
show_unknown_command_error(argv.first)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private_class_method def self.show_usage_and_exit
|
24
|
+
puts "Usage: coverage-reporter <command> [options]"
|
25
|
+
print_commands_list
|
26
|
+
exit 1
|
27
|
+
end
|
28
|
+
|
29
|
+
private_class_method def self.show_unknown_command_error(command)
|
30
|
+
puts "Unknown command: #{command}"
|
31
|
+
print_commands_list
|
32
|
+
exit 1
|
33
|
+
end
|
34
|
+
|
35
|
+
private_class_method def self.print_commands_list
|
36
|
+
puts ""
|
37
|
+
puts "Commands:"
|
38
|
+
puts " report Generate coverage report and post comments"
|
39
|
+
puts " collate Collate multiple coverage files"
|
40
|
+
puts ""
|
41
|
+
puts "Use 'coverage-reporter <command> --help' for command-specific options"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CoverageReporter
|
4
|
+
# Analyzes coverage data against diff data to find uncovered lines in changed code
|
5
|
+
# and calculates coverage statistics
|
6
|
+
#
|
7
|
+
# @param uncovered_ranges [Hash] Uncovered data where:
|
8
|
+
# - Keys are filenames (e.g., "app/models/user.rb")
|
9
|
+
# - Values are arrays of ranges representing uncovered lines
|
10
|
+
# - Example: { "app/models/user.rb" => [[12,14],[29,30]] }
|
11
|
+
#
|
12
|
+
# @param modified_ranges [Hash] Modified data where:
|
13
|
+
# - Keys are filenames (e.g., "app/models/user.rb")
|
14
|
+
# - Values are arrays of arrays representing modified or new line ranges
|
15
|
+
# - Example: { "app/services/foo.rb" => [[100,120]] }
|
16
|
+
class CoverageAnalyzer
|
17
|
+
def initialize(uncovered_ranges:, modified_ranges:)
|
18
|
+
@uncovered_ranges = uncovered_ranges
|
19
|
+
@modified_ranges = modified_ranges
|
20
|
+
end
|
21
|
+
|
22
|
+
def call
|
23
|
+
logger.debug("Starting coverage analysis for #{@modified_ranges.size} modified files")
|
24
|
+
|
25
|
+
intersections = {}
|
26
|
+
total_modified_lines = 0
|
27
|
+
total_uncovered_modified_lines = 0
|
28
|
+
|
29
|
+
@modified_ranges.each do |file, modified_ranges|
|
30
|
+
next if modified_ranges.nil? || modified_ranges.empty?
|
31
|
+
|
32
|
+
file_result = process_file(file, modified_ranges)
|
33
|
+
intersections.merge!(file_result[:intersections])
|
34
|
+
total_modified_lines += file_result[:modified_lines]
|
35
|
+
total_uncovered_modified_lines += file_result[:uncovered_lines]
|
36
|
+
end
|
37
|
+
|
38
|
+
coverage_percentage = calculate_percentage(total_modified_lines, total_uncovered_modified_lines)
|
39
|
+
|
40
|
+
log_results(intersections, total_modified_lines, total_uncovered_modified_lines, coverage_percentage)
|
41
|
+
|
42
|
+
build_result(intersections, total_modified_lines, total_uncovered_modified_lines, coverage_percentage)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def logger
|
48
|
+
CoverageReporter.logger
|
49
|
+
end
|
50
|
+
|
51
|
+
def process_file(file, modified_ranges)
|
52
|
+
# Calculate intersection for inline comments
|
53
|
+
uncovered_ranges = @uncovered_ranges[file] || []
|
54
|
+
intersecting_ranges = intersect_ranges(modified_ranges, uncovered_ranges)
|
55
|
+
|
56
|
+
# Calculate coverage statistics
|
57
|
+
file_modified_lines = count_lines_in_ranges(modified_ranges)
|
58
|
+
uncovered_modified_lines = count_intersecting_lines(modified_ranges, uncovered_ranges)
|
59
|
+
|
60
|
+
intersections = {}
|
61
|
+
# Only include files with actual intersections (matching original behavior)
|
62
|
+
intersections[file] = intersecting_ranges unless intersecting_ranges.empty?
|
63
|
+
|
64
|
+
{
|
65
|
+
intersections: intersections,
|
66
|
+
modified_lines: file_modified_lines,
|
67
|
+
uncovered_lines: uncovered_modified_lines
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
def fibonacci(num)
|
72
|
+
return num if num <= 1
|
73
|
+
|
74
|
+
fibonacci(num - 1) + fibonacci(num - 2)
|
75
|
+
end
|
76
|
+
|
77
|
+
def log_results(intersections, total_modified_lines, total_uncovered_modified_lines, coverage_percentage)
|
78
|
+
logger.debug("Identified modified uncovered intersection: #{intersections}")
|
79
|
+
logger.debug(
|
80
|
+
"Coverage calculation: #{total_modified_lines} total lines, " \
|
81
|
+
"#{total_uncovered_modified_lines} uncovered, #{coverage_percentage}% covered"
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
def build_result(intersections, total_modified_lines, total_uncovered_modified_lines, coverage_percentage)
|
86
|
+
{
|
87
|
+
intersections: intersections,
|
88
|
+
coverage_stats: {
|
89
|
+
total_modified_lines: total_modified_lines,
|
90
|
+
uncovered_modified_lines: total_uncovered_modified_lines,
|
91
|
+
covered_modified_lines: total_modified_lines - total_uncovered_modified_lines,
|
92
|
+
coverage_percentage: coverage_percentage
|
93
|
+
}
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
def count_lines_in_ranges(ranges)
|
98
|
+
ranges.sum { |range| range[1] - range[0] + 1 }
|
99
|
+
end
|
100
|
+
|
101
|
+
def count_intersecting_lines(modified_ranges, uncovered_ranges)
|
102
|
+
return 0 if uncovered_ranges.empty?
|
103
|
+
|
104
|
+
intersecting_lines = 0
|
105
|
+
i = j = 0
|
106
|
+
|
107
|
+
while i < modified_ranges.size && j < uncovered_ranges.size
|
108
|
+
modified_start, modified_end = modified_ranges[i]
|
109
|
+
uncovered_start, uncovered_end = uncovered_ranges[j]
|
110
|
+
|
111
|
+
# Find intersection
|
112
|
+
intersection_start = [modified_start, uncovered_start].max
|
113
|
+
intersection_end = [modified_end, uncovered_end].min
|
114
|
+
|
115
|
+
intersecting_lines += intersection_end - intersection_start + 1 if intersection_start <= intersection_end
|
116
|
+
|
117
|
+
# Move to next range
|
118
|
+
if modified_end < uncovered_end
|
119
|
+
i += 1
|
120
|
+
else
|
121
|
+
j += 1
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
intersecting_lines
|
126
|
+
end
|
127
|
+
|
128
|
+
# rubocop:disable Metrics/AbcSize
|
129
|
+
def intersect_ranges(changed, uncovered)
|
130
|
+
i = j = 0
|
131
|
+
result = []
|
132
|
+
while i < changed.size && j < uncovered.size
|
133
|
+
s = [changed[i][0], uncovered[j][0]].max
|
134
|
+
e = [changed[i][1], uncovered[j][1]].min
|
135
|
+
result << [s, e] if s <= e
|
136
|
+
if changed[i][1] < uncovered[j][1]
|
137
|
+
i += 1
|
138
|
+
else
|
139
|
+
j += 1
|
140
|
+
end
|
141
|
+
end
|
142
|
+
result
|
143
|
+
end
|
144
|
+
# rubocop:enable Metrics/AbcSize
|
145
|
+
|
146
|
+
def calculate_percentage(total_lines, uncovered_lines)
|
147
|
+
return 100.0 if total_lines == 0
|
148
|
+
|
149
|
+
covered_lines = total_lines - uncovered_lines
|
150
|
+
((covered_lines.to_f / total_lines) * 100).round(2)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CoverageReporter
|
4
|
+
class CoverageCollator
|
5
|
+
def initialize(options={})
|
6
|
+
@coverage_dir = options[:coverage_dir]
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
require "simplecov"
|
11
|
+
require "simplecov_json_formatter"
|
12
|
+
require "coverage_reporter/simple_cov/patches/result_hash_formatter_patch"
|
13
|
+
|
14
|
+
# Collate JSON coverage reports and generate both HTML and JSON outputs
|
15
|
+
files = Dir["#{coverage_dir}/resultset-*.json"]
|
16
|
+
abort "No coverage JSON files found to collate" if files.empty?
|
17
|
+
|
18
|
+
puts "Collate coverage files: #{files.join(', ')}"
|
19
|
+
|
20
|
+
::SimpleCov.collate(files) do
|
21
|
+
formatter ::SimpleCov::Formatter::MultiFormatter.new(
|
22
|
+
[
|
23
|
+
::SimpleCov::Formatter::HTMLFormatter,
|
24
|
+
::SimpleCov::Formatter::JSONFormatter
|
25
|
+
]
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
puts "✅ Coverage merged and report generated."
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
attr_reader :coverage_dir
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module CoverageReporter
|
6
|
+
# Custom error classes for coverage file operations
|
7
|
+
class CoverageFileError < StandardError; end
|
8
|
+
class CoverageFileNotFoundError < CoverageFileError; end
|
9
|
+
class CoverageFileAccessError < CoverageFileError; end
|
10
|
+
class CoverageFileParseError < CoverageFileError; end
|
11
|
+
|
12
|
+
class CoverageReportLoader
|
13
|
+
def initialize(coverage_file_path)
|
14
|
+
@coverage_file_path = coverage_file_path
|
15
|
+
end
|
16
|
+
|
17
|
+
def call
|
18
|
+
content = read_file_content
|
19
|
+
parse_json_content(content)
|
20
|
+
rescue Errno::ENOENT
|
21
|
+
raise CoverageFileNotFoundError, "Coverage file not found: #{@coverage_file_path}"
|
22
|
+
rescue Errno::EACCES
|
23
|
+
raise CoverageFileAccessError, "Permission denied reading coverage file: #{@coverage_file_path}"
|
24
|
+
rescue JSON::ParserError => e
|
25
|
+
raise CoverageFileParseError, "Invalid JSON in coverage file #{@coverage_file_path}: #{e.message}"
|
26
|
+
rescue StandardError => e
|
27
|
+
raise CoverageFileError, "Unexpected error reading coverage file #{@coverage_file_path}: #{e.message}"
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :coverage_file_path
|
33
|
+
|
34
|
+
def read_file_content
|
35
|
+
File.read(@coverage_file_path)
|
36
|
+
end
|
37
|
+
|
38
|
+
def parse_json_content(content)
|
39
|
+
JSON.parse(content)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CoverageReporter
|
4
|
+
class GlobalComment
|
5
|
+
def initialize(coverage_percentage:, commit_sha:, report_url: nil, intersections: {})
|
6
|
+
@coverage_percentage = coverage_percentage
|
7
|
+
@commit_sha = commit_sha
|
8
|
+
@report_url = report_url
|
9
|
+
@intersections = intersections
|
10
|
+
@body = build_body
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :coverage_percentage, :commit_sha, :report_url, :intersections, :body
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def build_body
|
18
|
+
body = <<~MD
|
19
|
+
<!-- coverage-comment-marker -->
|
20
|
+
**Test Coverage Summary**
|
21
|
+
|
22
|
+
#{coverage_percentage < 100 ? '❌' : '✅'} **#{coverage_percentage}%** of changed lines are covered.
|
23
|
+
|
24
|
+
[View full report](#{report_url})
|
25
|
+
|
26
|
+
_Commit: #{commit_sha}_
|
27
|
+
MD
|
28
|
+
|
29
|
+
body += "\n\n#{coverage_summary_section}" if intersections.any?
|
30
|
+
|
31
|
+
body
|
32
|
+
end
|
33
|
+
|
34
|
+
def coverage_summary_section
|
35
|
+
<<~MD
|
36
|
+
**Coverage Summary**
|
37
|
+
|
38
|
+
| File | Uncovered Lines |
|
39
|
+
|------|----------------|
|
40
|
+
#{coverage_summary_table_rows}
|
41
|
+
MD
|
42
|
+
end
|
43
|
+
|
44
|
+
def coverage_summary_table_rows
|
45
|
+
intersections.map do |file, ranges|
|
46
|
+
formatted_ranges = ranges.map { |range| format_range(range) }.join(", ")
|
47
|
+
"| `#{file}` | #{formatted_ranges} |"
|
48
|
+
end.join("\n")
|
49
|
+
end
|
50
|
+
|
51
|
+
def format_range(range)
|
52
|
+
start_line, end_line = range
|
53
|
+
if start_line == end_line
|
54
|
+
start_line.to_s
|
55
|
+
else
|
56
|
+
"#{start_line}-#{end_line}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CoverageReporter
|
4
|
+
class GlobalCommentPoster
|
5
|
+
def initialize(pull_request:, global_comment:)
|
6
|
+
@pull_request = pull_request
|
7
|
+
@global_comment = global_comment
|
8
|
+
end
|
9
|
+
|
10
|
+
def call
|
11
|
+
ensure_global_comment
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
attr_reader :pull_request, :global_comment
|
17
|
+
|
18
|
+
def ensure_global_comment
|
19
|
+
comments = pull_request.global_comments
|
20
|
+
existing = comments.find { |c| c.body&.include?(GLOBAL_COMMENT_MARKER) }
|
21
|
+
|
22
|
+
if existing
|
23
|
+
pull_request.update_global_comment(id: existing.id, body: global_comment.body)
|
24
|
+
else
|
25
|
+
pull_request.add_global_comment(body: global_comment.body)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CoverageReporter
|
4
|
+
class InlineComment
|
5
|
+
attr_reader :path, :start_line, :line, :body
|
6
|
+
|
7
|
+
def initialize(path:, start_line:, line:, body:)
|
8
|
+
@path = path
|
9
|
+
@start_line = start_line
|
10
|
+
@line = line
|
11
|
+
@body = body
|
12
|
+
end
|
13
|
+
|
14
|
+
def single_line?
|
15
|
+
start_line == line
|
16
|
+
end
|
17
|
+
|
18
|
+
def range?
|
19
|
+
!single_line?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CoverageReporter
|
4
|
+
class InlineCommentFactory
|
5
|
+
def initialize(commit_sha:, intersection:)
|
6
|
+
@commit_sha = commit_sha
|
7
|
+
@intersection = intersection
|
8
|
+
end
|
9
|
+
|
10
|
+
def call
|
11
|
+
comments = []
|
12
|
+
|
13
|
+
@intersection.each do |path, ranges|
|
14
|
+
ranges.each do |start_line, line|
|
15
|
+
body = build_body(path:, start_line:, line:)
|
16
|
+
|
17
|
+
comments << InlineComment.new(
|
18
|
+
path: path,
|
19
|
+
start_line: start_line,
|
20
|
+
line: line,
|
21
|
+
body: body
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
comments
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
attr_reader :commit_sha, :intersection
|
32
|
+
|
33
|
+
def build_body(path:, start_line:, line:)
|
34
|
+
message = build_message(start_line, line)
|
35
|
+
"#{INLINE_COMMENT_MARKER}\n#{message}\n\n_File: #{path}, line #{start_line}_\n_Commit: #{commit_sha}_"
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_message(start_line, line)
|
39
|
+
if start_line == line
|
40
|
+
"❌ Line #{start_line} is not covered by tests."
|
41
|
+
else
|
42
|
+
"❌ Lines #{start_line}–#{line} are not covered by tests."
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|