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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 502fcd527b9376d80aa547f7d416fc6159c610c3806aee89b1ee47bf65b0d586
4
- data.tar.gz: 90d60ffe2f23649fa8efa86603761ad3985eb5d623c81b9e80b782320682d0cc
3
+ metadata.gz: c646220bc7af144054d14538534b4fdc0ee3dc6a0f0e3bcea0b26bf1611a9839
4
+ data.tar.gz: 644b7b1431164971025b9f5e2dcfd0d25a9ce9164764e76341080d597816be17
5
5
  SHA512:
6
- metadata.gz: 004ef1c9498a942deb8514942fc91764a49aa24c70ba3186efbceb1ed4554b935837f857735df18a6900fda0c7916fca245fd2d555002913ca17b8dd596a671e
7
- data.tar.gz: cd2007eaaa1eb78cd342c8ff43538d2878ca3fe756432b212b0534e0338fc7be02800ef26243ecf06911f3e10efebba5c85e9567bcf9b74353310032432ee157
6
+ metadata.gz: '089653dbdbdb8d9ff0321afdfc4fd5c01765045f51a949f2f37e08677f540dd10e1ed2a5b977edb81b0cf111ed78e7943de246977ca21c9c194cdc06e5a55b88'
7
+ data.tar.gz: 4c7e4d2379a58d932a6b06387b53a0e114b58b7124ad0a2f7ed4e64aef22e6e1a3170cefa4ec9d58d64356c42a122a686a7f322113134f2e2d6687824b6d313a
data/README.md CHANGED
@@ -3,35 +3,204 @@
3
3
  [![Gem Version](https://img.shields.io/gem/v/coverage-reporter)](https://rubygems.org/gems/coverage-reporter)
4
4
  [![Gem Downloads](https://img.shields.io/gem/dt/coverage-reporter)](https://www.ruby-toolbox.com/projects/coverage-reporter)
5
5
  [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gabrieltaylor/coverage-reporter/ci.yml)](https://github.com/gabrieltaylor/coverage-reporter/actions/workflows/ci.yml)
6
- [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/gabrieltaylor/coverage-reporter)](https://codeclimate.com/github/gabrieltaylor/coverage-reporter)
7
6
 
8
- Report code coverage from SimpleCov coverage reports to a Github pull request.
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
- - [Quick start](#quick-start)
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 conduct](#code-of-conduct)
15
- - [Contribution guide](#contribution-guide)
20
+ - [Code of Conduct](#code-of-conduct)
21
+ - [Contributing](#contributing)
16
22
 
17
- ## Quick start
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
- ```ruby
24
- require "coverage-reporter"
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 conduct
200
+ ## Code of Conduct
32
201
 
33
- Everyone interacting in this projects codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
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
- ## Contribution guide
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,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift("#{__dir__}/../lib")
5
+
6
+ require "coverage_reporter"
7
+
8
+ CoverageReporter::CLI.start(ARGV)
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ class CLI
5
+ def self.start(argv)
6
+ options = Options.parse(argv)
7
+ Runner.new(options).run
8
+ end
9
+ end
10
+ 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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoverageReporter
4
+ VERSION = "0.2.0"
5
+ 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.1.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-08-10 00:00:00.000000000 Z
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
- - lib/coverage/reporter.rb
21
- - lib/coverage/reporter/version.rb
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
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Coverage
4
- module Reporter
5
- VERSION = "0.1.0"
6
- end
7
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Coverage
4
- module Reporter
5
- autoload :VERSION, "coverage/reporter/version"
6
- end
7
- end