Skip to content

Commit b478eae

Browse files
GitHub annotations (#432)
* Add GitHub annotation generator * Rename to github-annotations * Add (unused) annotations type * Formatting * Adds open_file() and updates tests * Adds ability to use stdout and stderr in --format * Adds missing tests * Updates test_report_generator.py to be more pytest-ic * Expose the issues to the PR * Fully tested * Adds more tests * chore: rename format argument to github-annotations * Rename another instance of github to github-annotations * Rename file to remove reference to warning, update doc string * Undo change to open_file * Remove github-annotations error type for CI coverage job * Remove default as it does not work --------- Co-authored-by: kingbuzzman <buzzi.javier@gmail.com>
1 parent 535e477 commit b478eae

File tree

6 files changed

+150
-2
lines changed

6 files changed

+150
-2
lines changed

.github/workflows/verify.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ jobs:
9797
cp .coverage htmlcov/.coverage
9898
- name: Complete coverage
9999
run: |
100-
poetry run diff-cover coverage.xml --include-untracked
100+
poetry run diff-cover coverage.xml --include-untracked --format github-annotations:warning
101101
poetry run diff-quality --violations flake8 --include-untracked
102102
poetry run diff-quality --violations pylint --include-untracked
103103
- name: Upload single coverage artifact

diff_cover/diff_cover_tool.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from diff_cover.git_diff import GitDiffFileTool, GitDiffTool
1313
from diff_cover.git_path import GitPathTool
1414
from diff_cover.report_generator import (
15+
GitHubAnnotationsReportGenerator,
1516
HtmlReportGenerator,
1617
JsonReportGenerator,
1718
MarkdownReportGenerator,
@@ -269,6 +270,15 @@ def generate_coverage_report(
269270
with open_file(markdown_report, "wb") as output_file:
270271
reporter.generate_report(output_file)
271272

273+
if "github-annotations" in report_formats:
274+
# Github annotations are always written to stdout, but we can use different types
275+
reporter = GitHubAnnotationsReportGenerator(
276+
coverage,
277+
diff,
278+
report_formats["github-annotations"],
279+
)
280+
reporter.generate_report(sys.stdout.buffer)
281+
272282
# Generate the report for stdout
273283
reporter = StringReportGenerator(coverage, diff, show_uncovered)
274284
output_file = io.BytesIO() if quiet else sys.stdout.buffer

diff_cover/report_generator.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,27 @@ def __init__(self, violations_reporter, diff_reporter, show_uncovered=False):
420420
self.include_snippets = show_uncovered
421421

422422

423+
class GitHubAnnotationsReportGenerator(TemplateReportGenerator):
424+
"""
425+
Generate a diff coverage report for GitHub annotations.
426+
https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-a-debug-message
427+
https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-a-notice-message
428+
https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message
429+
https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
430+
"""
431+
432+
template_path = "github_coverage_annotations.txt"
433+
434+
def __init__(self, violations_reporter, diff_reporter, annotations_type):
435+
super().__init__(violations_reporter, diff_reporter)
436+
self.annotations_type = annotations_type
437+
438+
def _context(self):
439+
context = super().report_dict()
440+
context.update({"annotations_type": self.annotations_type})
441+
return context
442+
443+
423444
class HtmlReportGenerator(TemplateReportGenerator):
424445
"""
425446
Generate an HTML formatted diff coverage report.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% if src_stats %}
2+
{% for src_path, stats in src_stats|dictsort %}
3+
{% if stats.percent_covered < 100 %}
4+
{% for line in stats.violation_lines %}
5+
{% set splitLines = line.split("-") %}
6+
::{{ annotations_type }} file={{ src_path }},line={{ splitLines[0] }}{% if splitLines[1] %},endLine={{ splitLines[1] }}{% endif %},title=Missing Coverage::Line {{ line }} missing coverage
7+
{% endfor %}
8+
{% endif %}
9+
{% endfor %}
10+
{% endif %}

tests/test_integration.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
# pylint: disable=use-implicit-booleaness-not-comparison
33

44
"""High-level integration tests of diff-cover tool."""
5-
65
import json
76
import os
87
import os.path
98
import re
109
import shutil
10+
import textwrap
1111
from collections import defaultdict
1212
from pathlib import Path
1313
from subprocess import Popen
@@ -429,6 +429,54 @@ def test_expand_coverage_report_uncomplete_report(
429429
assert runbin(["coverage_missing_lines.xml", "--expand-coverage-report"]) == 0
430430
compare_console("expand_console_report.txt", capsys.readouterr().out)
431431

432+
def test_github_silent(self, runbin, patch_git_command, capsys):
433+
patch_git_command.set_stdout("git_diff_add.txt")
434+
assert (
435+
runbin(["coverage.xml", "--format", "github-annotations:notice", "-q"]) == 0
436+
)
437+
expected = textwrap.dedent(
438+
"""\
439+
::notice file=test_src.txt,line=2,title=Missing Coverage::Line 2 missing coverage
440+
::notice file=test_src.txt,line=4,title=Missing Coverage::Line 4 missing coverage
441+
::notice file=test_src.txt,line=6,title=Missing Coverage::Line 6 missing coverage
442+
::notice file=test_src.txt,line=8,title=Missing Coverage::Line 8 missing coverage
443+
::notice file=test_src.txt,line=10,title=Missing Coverage::Line 10 missing coverage
444+
"""
445+
)
446+
assert capsys.readouterr().out == expected
447+
448+
@pytest.mark.usefixtures("patch_git_command")
449+
def test_github_fully_covered(self, runbin, capsys):
450+
assert runbin(["coverage2.xml", "--format", "github-annotations:notice"]) == 0
451+
expected = textwrap.dedent(
452+
"""\
453+
-------------
454+
Diff Coverage
455+
Diff: origin/main...HEAD, staged and unstaged changes
456+
-------------
457+
No lines with coverage information in this diff.
458+
-------------
459+
460+
"""
461+
)
462+
assert capsys.readouterr().out == expected
463+
464+
def test_github_empty_diff(self, runbin, patch_git_command, capsys):
465+
patch_git_command.set_stdout("")
466+
assert runbin(["coverage.xml", "--format", "github-annotations:notice"]) == 0
467+
expected = textwrap.dedent(
468+
"""\
469+
-------------
470+
Diff Coverage
471+
Diff: origin/main...HEAD, staged and unstaged changes
472+
-------------
473+
No lines with coverage information in this diff.
474+
-------------
475+
476+
"""
477+
)
478+
assert capsys.readouterr().out == expected
479+
432480
def test_real_world_cpp_lcov_coverage(self, runbin, patch_git_command, capsys):
433481
"""Test with real C++ LCOV coverage data"""
434482
# Create git diff for C++ files

tests/test_report_generator.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from diff_cover.diff_reporter import BaseDiffReporter
1111
from diff_cover.report_generator import (
1212
BaseReportGenerator,
13+
GitHubAnnotationsReportGenerator,
1314
HtmlReportGenerator,
1415
JsonReportGenerator,
1516
MarkdownReportGenerator,
@@ -407,6 +408,64 @@ def test_empty_report(self):
407408
self.assert_report(expected)
408409

409410

411+
class TestGitHubAnnotationsReportGenerator(BaseReportGeneratorTest):
412+
413+
@pytest.fixture
414+
def report(self, coverage, diff):
415+
# Create a concrete instance of a report generator
416+
return GitHubAnnotationsReportGenerator(coverage, diff, "warning")
417+
418+
@pytest.mark.usefixtures("use_default_values")
419+
def test_generate_report(self):
420+
# Verify that we got the expected string
421+
expected = dedent(
422+
"""
423+
::warning file=file1.py,line=10,endLine=11,title=Missing Coverage::Line 10-11 missing coverage
424+
::warning file=subdir/file2.py,line=10,endLine=11,title=Missing Coverage::Line 10-11 missing coverage
425+
"""
426+
).strip()
427+
428+
self.assert_report(expected)
429+
430+
def test_single_line(
431+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
432+
):
433+
diff.src_paths_changed.return_value = ["file.py"]
434+
diff_lines_changed.update({"file.py": list(range(100))})
435+
coverage_violations.update({"file.py": [Violation(10, None)]})
436+
coverage_measured_lines.update({"file.py": [2]})
437+
438+
# Verify that we got the expected string
439+
expected = dedent(
440+
"""
441+
::warning file=file.py,line=10,title=Missing Coverage::Line 10 missing coverage
442+
"""
443+
).strip()
444+
445+
self.assert_report(expected)
446+
447+
def test_hundred_percent(
448+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
449+
):
450+
# Have the dependencies return an empty report
451+
diff.src_paths_changed.return_value = ["file.py"]
452+
diff_lines_changed.update({"file.py": list(range(100))})
453+
coverage_violations.update({"file.py": []})
454+
coverage_measured_lines.update({"file.py": [2]})
455+
456+
expected = ""
457+
458+
self.assert_report(expected)
459+
460+
def test_empty_report(self):
461+
# Have the dependencies return an empty report
462+
# (this is the default)
463+
464+
expected = ""
465+
466+
self.assert_report(expected)
467+
468+
410469
class TestHtmlReportGenerator(BaseReportGeneratorTest):
411470

412471
@pytest.fixture

0 commit comments

Comments
 (0)