coverage-reporter 0.1.0 → 0.2.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 +10 -0
- data/lib/coverage_reporter/coverage_analyzer.rb +153 -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.rb +85 -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 +49 -0
- data/lib/tasks/coverage.rake +25 -0
- metadata +51 -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: c646220bc7af144054d14538534b4fdc0ee3dc6a0f0e3bcea0b26bf1611a9839
|
4
|
+
data.tar.gz: 644b7b1431164971025b9f5e2dcfd0d25a9ce9164764e76341080d597816be17
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '089653dbdbdb8d9ff0321afdfc4fd5c01765045f51a949f2f37e08677f540dd10e1ed2a5b977edb81b0cf111ed78e7943de246977ca21c9c194cdc06e5a55b88'
|
7
|
+
data.tar.gz: 4c7e4d2379a58d932a6b06387b53a0e114b58b7124ad0a2f7ed4e64aef22e6e1a3170cefa4ec9d58d64356c42a122a686a7f322113134f2e2d6687824b6d313a
|
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
|
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,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,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
|
@@ -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,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
|
5
|
+
module CoverageReporter
|
6
|
+
class Options
|
7
|
+
def self.defaults
|
8
|
+
{
|
9
|
+
commit_sha: ENV.fetch("COMMIT_SHA", nil),
|
10
|
+
coverage_report_path: ENV.fetch("COVERAGE_REPORT_PATH", "coverage/coverage.json"),
|
11
|
+
github_token: ENV.fetch("GITHUB_TOKEN", nil),
|
12
|
+
pr_number: ENV.fetch("PR_NUMBER", nil),
|
13
|
+
repo: normalize_repo(ENV.fetch("REPO", nil)),
|
14
|
+
report_url: ENV.fetch("REPORT_URL", nil)
|
15
|
+
}
|
16
|
+
end
|
17
|
+
|
18
|
+
# rubocop:disable Metrics/AbcSize
|
19
|
+
# rubocop:disable Metrics/MethodLength
|
20
|
+
def self.parse(argv)
|
21
|
+
opts = defaults.dup
|
22
|
+
|
23
|
+
parser = OptionParser.new do |o|
|
24
|
+
o.banner = "Usage: coverage-reporter [options]"
|
25
|
+
o.on("--report-url URL", "Report URL used for links (default: $REPORT_URL)") do |v|
|
26
|
+
opts[:report_url] = v
|
27
|
+
end
|
28
|
+
o.on("--commit-sha SHA", "GitHub commit SHA (default: $COMMIT_SHA)") do |v|
|
29
|
+
opts[:commit_sha] = v
|
30
|
+
end
|
31
|
+
o.on(
|
32
|
+
"--coverage-report-path PATH",
|
33
|
+
"Path to merged SimpleCov coverage.json (default: coverage/coverage.json)"
|
34
|
+
) do |v|
|
35
|
+
opts[:coverage_report_path] = v
|
36
|
+
end
|
37
|
+
o.on("--github-token TOKEN", "GitHub token (default: $GITHUB_TOKEN)") { |v| opts[:github_token] = v }
|
38
|
+
o.on("--pr-number NUMBER", "GitHub pull request number (default: $PR_NUMBER)") do |v|
|
39
|
+
opts[:pr_number] = v
|
40
|
+
end
|
41
|
+
o.on("--repo REPO", "GitHub repository (default: $REPO)") do |v|
|
42
|
+
opts[:repo] = normalize_repo(v)
|
43
|
+
end
|
44
|
+
o.on_tail("-h", "--help", "Show help") do
|
45
|
+
puts o
|
46
|
+
exit 0
|
47
|
+
end
|
48
|
+
end
|
49
|
+
# rubocop:enable Metrics/AbcSize
|
50
|
+
# rubocop:enable Metrics/MethodLength
|
51
|
+
parser.parse!(argv)
|
52
|
+
|
53
|
+
validate!(opts)
|
54
|
+
opts
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.validate!(opts)
|
58
|
+
missing = collect_missing_options(opts)
|
59
|
+
return if missing.empty?
|
60
|
+
|
61
|
+
abort "coverage-reporter: missing required option(s): #{missing.join(', ')}"
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.collect_missing_options(opts)
|
65
|
+
required_options = {
|
66
|
+
github_token: "--github-token or $GITHUB_TOKEN",
|
67
|
+
repo: "--repo or $REPO",
|
68
|
+
pr_number: "--pr-number or $PR_NUMBER",
|
69
|
+
commit_sha: "--commit-sha or $COMMIT_SHA"
|
70
|
+
}
|
71
|
+
|
72
|
+
required_options.filter_map do |key, message|
|
73
|
+
message if opts[key].to_s.strip.empty?
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.normalize_repo(repo)
|
78
|
+
return repo if repo.nil? || repo.strip.empty?
|
79
|
+
|
80
|
+
repo.strip
|
81
|
+
.gsub(%r{^(https://github\.com/|git@github\.com:)}, "")
|
82
|
+
.gsub(/\.git$/, "")
|
83
|
+
end
|
84
|
+
end
|
85
|
+
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,49 @@
|
|
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_report_loader"
|
38
|
+
require_relative "coverage_reporter/global_comment"
|
39
|
+
require_relative "coverage_reporter/global_comment_poster"
|
40
|
+
require_relative "coverage_reporter/inline_comment"
|
41
|
+
require_relative "coverage_reporter/inline_comment_factory"
|
42
|
+
require_relative "coverage_reporter/inline_comment_poster"
|
43
|
+
require_relative "coverage_reporter/modified_ranges_extractor"
|
44
|
+
require_relative "coverage_reporter/options"
|
45
|
+
require_relative "coverage_reporter/pull_request"
|
46
|
+
require_relative "coverage_reporter/runner"
|
47
|
+
require_relative "coverage_reporter/uncovered_ranges_extractor"
|
48
|
+
require_relative "coverage_reporter/simple_cov/patches/result_hash_formatter_patch"
|
49
|
+
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
|
metadata
CHANGED
@@ -1,24 +1,69 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: coverage-reporter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Gabriel Taylor Russ
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
11
|
-
dependencies:
|
10
|
+
date: 2025-10-20 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: octokit
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - ">="
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - ">="
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: simplecov_json_formatter
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
12
40
|
email:
|
13
41
|
- gabriel.taylor.russ@gmail.com
|
14
|
-
executables:
|
42
|
+
executables:
|
43
|
+
- coverage-reporter
|
15
44
|
extensions: []
|
16
45
|
extra_rdoc_files: []
|
17
46
|
files:
|
18
47
|
- LICENSE.txt
|
19
48
|
- README.md
|
20
|
-
-
|
21
|
-
- lib/
|
49
|
+
- exe/coverage-reporter
|
50
|
+
- lib/coverage_reporter.rb
|
51
|
+
- lib/coverage_reporter/cli.rb
|
52
|
+
- lib/coverage_reporter/coverage_analyzer.rb
|
53
|
+
- lib/coverage_reporter/coverage_report_loader.rb
|
54
|
+
- lib/coverage_reporter/global_comment.rb
|
55
|
+
- lib/coverage_reporter/global_comment_poster.rb
|
56
|
+
- lib/coverage_reporter/inline_comment.rb
|
57
|
+
- lib/coverage_reporter/inline_comment_factory.rb
|
58
|
+
- lib/coverage_reporter/inline_comment_poster.rb
|
59
|
+
- lib/coverage_reporter/modified_ranges_extractor.rb
|
60
|
+
- lib/coverage_reporter/options.rb
|
61
|
+
- lib/coverage_reporter/pull_request.rb
|
62
|
+
- lib/coverage_reporter/runner.rb
|
63
|
+
- lib/coverage_reporter/simple_cov/patches/result_hash_formatter_patch.rb
|
64
|
+
- lib/coverage_reporter/uncovered_ranges_extractor.rb
|
65
|
+
- lib/coverage_reporter/version.rb
|
66
|
+
- lib/tasks/coverage.rake
|
22
67
|
homepage: https://github.com/gabrieltaylor/coverage-reporter
|
23
68
|
licenses:
|
24
69
|
- MIT
|