Skip to content

Commit dbdcde1

Browse files
authored
CI/code coverage improvement (#71)
* fix(ci): Remove unsupported branch coverage * feat(ci): Compress uncovered lines into ranges AI-assisted-by: Gemini 2.5 Pro correct readme feat(ci): check for uncovered lines in PRs Adds a new step to the CI workflow to check for uncovered lines in pull requests. This is done by: - A new Python script that compares the git diff of the PR with the Cobertura coverage report. - The script outputs a list of uncovered lines that are part of the PR's changes. - The CI workflow is updated to run this script and post the results as a comment on the PR. - The PR comment is updated on subsequent pushes to avoid spamming. AI-assisted-by: Gemini 2.5 Pro fix(ci): always post new coverage comment Removes the logic for editing existing PR comments for code coverage. The CI will now post a new comment on every run, as requested. This resolves the error from the previous CI run where the '--edit' flag for 'gh pr comment' was causing a failure. AI-assisted-by: Gemini 2.5 Pro * correct script path
1 parent fb0546a commit dbdcde1

3 files changed

Lines changed: 146 additions & 9 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import subprocess
5+
import sys
6+
import xml.etree.ElementTree as ET
7+
from collections import defaultdict
8+
9+
10+
def get_pr_diff(base_branch):
11+
"""
12+
Get the diff of the current branch against the base branch and return a dictionary
13+
mapping file paths to a set of added or modified line numbers.
14+
"""
15+
diff_files = defaultdict(set)
16+
# We fetch the base branch to ensure it's available for diffing
17+
subprocess.run(
18+
["git", "fetch", "origin", base_branch],
19+
check=True,
20+
capture_output=True,
21+
)
22+
result = subprocess.run(
23+
["git", "diff", f"origin/{base_branch}", "--unified=0"],
24+
capture_output=True,
25+
text=True,
26+
check=True,
27+
)
28+
file_path = None
29+
for line in result.stdout.splitlines():
30+
if line.startswith("+++ b/"):
31+
file_path = line[6:]
32+
elif line.startswith("@@"):
33+
parts = line.split(" ")
34+
if len(parts) > 2 and parts[2].startswith("+"):
35+
line_info = parts[2][1:].split(",")
36+
start_line = int(line_info[0])
37+
num_lines = int(line_info[1]) if len(line_info) > 1 else 1
38+
for i in range(num_lines):
39+
diff_files[file_path].add(start_line + i)
40+
return diff_files
41+
42+
43+
def get_uncovered_lines(cobertura_path):
44+
"""
45+
Parse the Cobertura XML report and return a dictionary mapping file paths
46+
to a set of uncovered line numbers.
47+
"""
48+
uncovered_lines = defaultdict(set)
49+
if not os.path.exists(cobertura_path):
50+
print(f"Error: Coverage report not found at {cobertura_path}", file=sys.stderr)
51+
sys.exit(1)
52+
53+
tree = ET.parse(cobertura_path)
54+
root = tree.getroot()
55+
packages = root.find("packages")
56+
if packages is None:
57+
return uncovered_lines
58+
59+
for package in packages.findall("package"):
60+
classes = package.find("classes")
61+
if classes is None:
62+
continue
63+
for klass in classes.findall("class"):
64+
file_path = klass.attrib["filename"]
65+
lines = klass.find("lines")
66+
if lines is None:
67+
continue
68+
for line in lines.findall("line"):
69+
if line.attrib["hits"] == "0":
70+
uncovered_lines[file_path].add(int(line.attrib["number"]))
71+
return uncovered_lines
72+
73+
def main():
74+
if len(sys.argv) < 2:
75+
print("Usage: python check_pr_coverage.py <base-branch>", file=sys.stderr)
76+
sys.exit(1)
77+
78+
base_branch = sys.argv[1]
79+
cobertura_xml_path = "./coverage-reports/cobertura.xml"
80+
81+
try:
82+
pr_diff = get_pr_diff(base_branch)
83+
uncovered_lines = get_uncovered_lines(cobertura_xml_path)
84+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
85+
print(f"Error: {e}", file=sys.stderr)
86+
sys.exit(1)
87+
88+
uncovered_in_pr = defaultdict(list)
89+
for file_path, changed_lines in pr_diff.items():
90+
if file_path in uncovered_lines:
91+
intersection = sorted(list(changed_lines.intersection(uncovered_lines[file_path])))
92+
if intersection:
93+
uncovered_in_pr[file_path] = intersection
94+
95+
if uncovered_in_pr:
96+
print("### 🚨 Uncovered lines in this PR\n")
97+
for file_path, lines in uncovered_in_pr.items():
98+
lines_str = ", ".join(map(str, lines))
99+
print(f"- **{file_path}:** `{lines_str}`")
100+
else:
101+
print("### ✅ All new and modified lines are covered by tests!")
102+
103+
104+
if __name__ == "__main__":
105+
main()

.github/workflows/ci.yml

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,6 @@ jobs:
243243
--verbose \
244244
--all-features \
245245
--workspace \
246-
--branch \
247246
--engine llvm \
248247
--timeout 120 \
249248
--out Xml \
@@ -269,15 +268,35 @@ jobs:
269268
import xml.etree.ElementTree as ET
270269
import os
271270
271+
def to_ranges(numbers):
272+
if not numbers:
273+
return ""
274+
numbers = sorted([int(n) for n in numbers])
275+
ranges = []
276+
start = end = numbers[0]
277+
for n in numbers[1:]:
278+
if n == end + 1:
279+
end = n
280+
else:
281+
if start == end:
282+
ranges.append(str(start))
283+
else:
284+
ranges.append(f"{start}-{end}")
285+
start = end = n
286+
if start == end:
287+
ranges.append(str(start))
288+
else:
289+
ranges.append(f"{start}-{end}")
290+
return ", ".join(ranges)
291+
272292
tree = ET.parse("./coverage-reports/cobertura.xml")
273293
root = tree.getroot()
274294
line_rate = float(root.attrib["line-rate"]) * 100
275-
branch_rate = float(root.attrib["branch-rate"]) * 100
276295
277296
with open("./coverage-reports/coverage-summary.md", "w") as f:
278297
f.write("### 📊 Code Coverage Summary\n\n")
279-
f.write("| File | Line Coverage | Branch Coverage | Uncovered Lines |\n")
280-
f.write("|------|---------------|-----------------|-----------------|\n")
298+
f.write("| File | Line Coverage | Uncovered Lines |\n")
299+
f.write("|------|---------------|-----------------|\n")
281300
282301
packages = root.find("packages")
283302
all_classes = []
@@ -295,7 +314,6 @@ jobs:
295314
if "target/debug/build" in filename:
296315
continue
297316
line_rate_class = float(klass.attrib["line-rate"]) * 100
298-
branch_rate_class = float(klass.attrib["branch-rate"]) * 100
299317
300318
uncovered_lines = []
301319
lines = klass.find("lines")
@@ -304,10 +322,10 @@ jobs:
304322
if line.attrib["hits"] == "0":
305323
uncovered_lines.append(line.attrib["number"])
306324
307-
uncovered_lines_str = ", ".join(uncovered_lines)
308-
f.write(f"| `{filename}` | {line_rate_class:.2f}% | {branch_rate_class:.2f}% | `{uncovered_lines_str}` |\n")
325+
uncovered_lines_str = to_ranges(uncovered_lines)
326+
f.write(f"| `{filename}` | {line_rate_class:.2f}% | `{uncovered_lines_str}` |\n")
309327
310-
f.write(f"| **Total** | **{line_rate:.2f}%** | **{branch_rate:.2f}%** | | \n")
328+
f.write(f"| **Total** | **{line_rate:.2f}%** | | \n")
311329
'
312330
- name: Add Markdown summary table to Job Summary
313331
run: |
@@ -330,21 +348,35 @@ jobs:
330348
steps:
331349
- name: Checkout code
332350
uses: actions/checkout@v4
351+
with:
352+
# Fetch the full history to allow diff-coverage checking
353+
fetch-depth: 0
333354
- name: Download coverage report artifact
334355
uses: actions/download-artifact@v4
335356
with:
336357
name: coverage-reports
337358
path: ./coverage-reports/
338359

360+
- name: Check for uncovered lines in PR
361+
id: pr_coverage_check
362+
run: |
363+
set -e
364+
echo "PR_COVERAGE_REPORT=$(./.github/scripts/check_pr_coverage.py ${{ github.base_ref }})" >> $GITHUB_OUTPUT
365+
339366
- name: Post Coverage Comment
340367
env:
341368
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
342369
PR_NUMBER: ${{ github.event.pull_request.number }}
343370
run: |
344371
MARKER="<!-- gemini-coverage-comment -->"
345372
BODY=$(cat ./coverage-reports/coverage-summary.md)
373+
PR_COVERAGE_REPORT="${{ steps.pr_coverage_check.outputs.PR_COVERAGE_REPORT }}"
374+
346375
COMMENT_BODY="$MARKER
376+
$PR_COVERAGE_REPORT
377+
347378
$BODY"
379+
348380
gh pr comment "$PR_NUMBER" --body "$COMMENT_BODY"
349381
350382
benchmarks:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ This project uses GitHub Actions for continuous integration. The CI pipeline is
435435

436436
- **Linting and Formatting**: Ensures code style and quality using `cargo fmt` and `cargo clippy`.
437437
- **Testing**: Runs the full test suite on stable, beta, and MSRV Rust across Linux, Windows, and macOS.
438-
- **Code Coverage**: Generates a code coverage report using `cargo-tarpaulin` and uploads it to Codecov.
438+
- **Code Coverage**: Generates a code coverage report using `cargo-tarpaulin`.
439439
- **Security Audit**: Scans for vulnerabilities using `cargo audit`.
440440
- **Docker Build**: Validates that the Docker image can be built and run successfully.
441441

0 commit comments

Comments
 (0)