Repository: py-cov-action/python-coverage-comment-action
Pull request #96 (2022-11-03 00:10:13)

[New feature] Optional annotations for not covered lines on pull requests
This PR extends the action with a setting that makes it possible that all not covered lines can be [annotated](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-warning-message) in PR mode.

![2](https://user-images.githubusercontent.com/81773954/199728215-11073054-551a-4ec3-9a7f-6d6db03727be.png)
![1](https://user-images.githubusercontent.com/81773954/199728205-eb95c0d4-b75f-44a1-a19e-83af256728c5.png)


github-actions[bot]: ## Coverage report
The coverage rate went from `100%` to `100%` :arrow_down:
The branch rate is `99%`.

`95%` of new lines are covered.

<details>
<summary>Diff Coverage details (click to unfold)</summary>

### coverage_comment/github.py
`100%` of new lines are covered (`99%` of the complete file).

### coverage_comment/settings.py
`75%` of new lines are covered (`99%` of the complete file).
Missing lines: `66`

### coverage_comment/annotations.py
`100%` of new lines are covered (`100%` of the complete file).

### coverage_comment/main.py
`100%` of new lines are covered (`100%` of the complete file).

</details>
<!-- This comment was produced by python-coverage-comment-action -->

Patch:
diff --git a/README.md b/README.md
index 424bd21..3dbed38 100644
--- a/README.md
+++ b/README.md
@@ -231,6 +231,14 @@ jobs:
     # If true, will run `coverage combine` before reading the `.coverage` file.
     MERGE_COVERAGE_FILES: false
 
+    # If true, will create an annotation on every line with missing coverage on a pull request.
+    ANNOTATE_MISSING_LINES: false
+
+    # Only needed if ANNOTATE_MISSING_LINES is set to true. This parameter allows you to choose between
+    # notice, warning and error as annotation type. For more information look here:
+    # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-notice-message
+    ANNOTATION_TYPE: warning
+
     # If true, produces more output. Useful for debugging.
     VERBOSE: false
 
diff --git a/action.yml b/action.yml
index 5db4154..d17aa1a 100644
--- a/action.yml
+++ b/action.yml
@@ -55,6 +55,16 @@ inputs:
     description: >
       If true, will run `coverage combine` before reading the `.coverage` file.
     default: false
+  ANNOTATE_MISSING_LINES:
+    description: >
+      If true, will create an annotation on every line with missing coverage on a pull request.
+    default: false
+  ANNOTATION_TYPE:
+    description: >
+      Only needed if ANNOTATE_MISSING_LINES is set to true. This parameter allows you to choose between
+      notice, warning and error as annotation type. For more information look here:
+      https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-notice-message
+    default: warning
   VERBOSE:
     description: >
       If true, produces more output. Useful for debugging.
@@ -77,4 +87,6 @@ runs:
     MINIMUM_GREEN: ${{ inputs.MINIMUM_GREEN }}
     MINIMUM_ORANGE: ${{ inputs.MINIMUM_ORANGE }}
     MERGE_COVERAGE_FILES: ${{ inputs.MERGE_COVERAGE_FILES }}
+    ANNOTATE_MISSING_LINES: ${{ inputs.ANNOTATE_MISSING_LINES }}
+    ANNOTATION_TYPE: ${{ inputs.ANNOTATION_TYPE }}
     VERBOSE: ${{ inputs.VERBOSE }}
diff --git a/coverage_comment/annotations.py b/coverage_comment/annotations.py
new file mode 100644
index 0000000..9b06524
--- /dev/null
+++ b/coverage_comment/annotations.py
@@ -0,0 +1,18 @@
+from coverage_comment import coverage as coverage_module
+from coverage_comment import github
+
+MISSING_LINES_GROUP_TITLE = "Annotations of lines with missing coverage"
+
+
+def create_pr_annotations(annotation_type: str, coverage: coverage_module.Coverage):
+    github.send_workflow_command(
+        command="group", command_value=MISSING_LINES_GROUP_TITLE
+    )
+
+    for filename, file_coverage in coverage.files.items():
+        for missing_line in file_coverage.missing_lines:
+            github.create_missing_coverage_annotation(
+                annotation_type=annotation_type, file=filename, line=missing_line
+            )
+
+    github.send_workflow_command(command="endgroup", command_value="")
diff --git a/coverage_comment/github.py b/coverage_comment/github.py
index e322d89..a52d6cc 100644
--- a/coverage_comment/github.py
+++ b/coverage_comment/github.py
@@ -8,6 +8,7 @@ import zipfile
 from coverage_comment import github_client, log
 
 GITHUB_ACTIONS_LOGIN = "github-actions[bot]"
+MISSING_COVERAGE_MESSAGE = "This line has no coverage"
 
 
 class CannotDeterminePR(Exception):
@@ -158,3 +159,33 @@ def set_output(github_output: pathlib.Path, **kwargs: bool) -> None:
         with github_output.open("a") as f:
             for key, value in kwargs.items():
                 f.write(f"{key}={json.dumps(value)}\n")
+
+
+def escape_property(s: str) -> str:
+    return (
+        s.replace("%", "%25")
+        .replace("\r", "%0D")
+        .replace("\n", "%0A")
+        .replace(":", "%3A")
+        .replace(",", "%2C")
+    )
+
+
+def escape_data(s: str) -> str:
+    return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
+
+
+def send_workflow_command(command: str, command_value: str, **kwargs: str) -> None:
+    values_listed = [f"{key}={escape_property(value)}" for key, value in kwargs.items()]
+
+    context = f" {','.join(values_listed)}" if values_listed else ""
+    print(f"::{command}{context}::{escape_data(command_value)}")
+
+
+def create_missing_coverage_annotation(annotation_type: str, file: str, line: int):
+    send_workflow_command(
+        command=annotation_type,
+        command_value=MISSING_COVERAGE_MESSAGE,
+        file=file,
+        line=str(line),
+    )
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index 64d5fac..12758ce 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -5,7 +5,7 @@ import sys
 
 import httpx
 
-from coverage_comment import comment_file, communication
+from coverage_comment import annotations, comment_file, communication
 from coverage_comment import coverage as coverage_module
 from coverage_comment import (
     files,
@@ -68,6 +68,11 @@ def action(
     if event_name in {"pull_request", "push"}:
         coverage = coverage_module.get_coverage_info(merge=config.MERGE_COVERAGE_FILES)
         if event_name == "pull_request":
+            if config.ANNOTATE_MISSING_LINES:
+                annotations.create_pr_annotations(
+                    annotation_type=config.ANNOTATION_TYPE, coverage=coverage
+                )
+
             return generate_comment(
                 config=config,
                 coverage=coverage,
diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py
index 4673747..2057335 100644
--- a/coverage_comment/settings.py
+++ b/coverage_comment/settings.py
@@ -8,6 +8,10 @@ class MissingEnvironmentVariable(Exception):
     pass
 
 
+class InvalidAnnotationType(Exception):
+    pass
+
+
 def path_below(path_str: str) -> pathlib.Path:
     try:
         return pathlib.Path(path_str).resolve().relative_to(pathlib.Path.cwd())
@@ -39,6 +43,8 @@ class Config:
     MINIMUM_GREEN: float = 100.0
     MINIMUM_ORANGE: float = 70.0
     MERGE_COVERAGE_FILES: bool = False
+    ANNOTATE_MISSING_LINES: bool = False
+    ANNOTATION_TYPE: str = "warning"
     VERBOSE: bool = False
     # Only for debugging, not exposed in the action:
     FORCE_WORKFLOW_RUN: bool = False
@@ -60,6 +66,18 @@ class Config:
     def clean_merge_coverage_files(cls, value: str) -> bool:
         return str_to_bool(value)
 
+    @classmethod
+    def clean_annotate_missing_lines(cls, value: str) -> bool:
+        return str_to_bool(value)
+
+    @classmethod
+    def clean_annotation_type(cls, value: str) -> str:
+        if value not in {"notice", "warning", "error"}:
+            raise InvalidAnnotationType(
+                f"The annotation type {value} is not valid. Please choose from notice, warning or error"
+            )
+        return value
+
     @classmethod
     def clean_verbose(cls, value: str) -> bool:
         return str_to_bool(value)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 2746276..e24896c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -214,6 +214,65 @@ def coverage_obj_no_branch():
     )
 
 
+@pytest.fixture
+def coverage_obj_many_missing_lines():
+    return coverage_module.Coverage(
+        meta=coverage_module.CoverageMetadata(
+            version="1.2.3",
+            timestamp=datetime.datetime(2000, 1, 1),
+            branch_coverage=True,
+            show_contexts=False,
+        ),
+        info=coverage_module.CoverageInfo(
+            covered_lines=7,
+            num_statements=10,
+            percent_covered=0.8,
+            missing_lines=12,
+            excluded_lines=0,
+            num_branches=2,
+            num_partial_branches=1,
+            covered_branches=1,
+            missing_branches=1,
+        ),
+        files={
+            "codebase/main.py": coverage_module.FileCoverage(
+                path="codebase/main.py",
+                executed_lines=[1, 2, 5, 6, 9],
+                missing_lines=[3, 7, 13, 21, 123],
+                excluded_lines=[],
+                info=coverage_module.CoverageInfo(
+                    covered_lines=5,
+                    num_statements=10,
+                    percent_covered=0.5,
+                    missing_lines=5,
+                    excluded_lines=0,
+                    num_branches=2,
+                    num_partial_branches=1,
+                    covered_branches=1,
+                    missing_branches=1,
+                ),
+            ),
+            "codebase/caller.py": coverage_module.FileCoverage(
+                path="codebase/caller.py",
+                executed_lines=[1, 2, 5],
+                missing_lines=[13, 21, 212],
+                excluded_lines=[],
+                info=coverage_module.CoverageInfo(
+                    covered_lines=3,
+                    num_statements=6,
+                    percent_covered=0.5,
+                    missing_lines=3,
+                    excluded_lines=0,
+                    num_branches=2,
+                    num_partial_branches=1,
+                    covered_branches=1,
+                    missing_branches=1,
+                ),
+            ),
+        },
+    )
+
+
 @pytest.fixture
 def diff_coverage_obj():
     return coverage_module.DiffCoverage(
diff --git a/tests/end_to_end/repo/.github/workflows/ci.yml b/tests/end_to_end/repo/.github/workflows/ci.yml
index a5eb6f2..bc92eac 100644
--- a/tests/end_to_end/repo/.github/workflows/ci.yml
+++ b/tests/end_to_end/repo/.github/workflows/ci.yml
@@ -31,6 +31,8 @@ jobs:
         uses: py-cov-action/python-coverage-comment-action@__ACTION_REF__
         with:
           GITHUB_TOKEN: ${{ github.token }}
+          ANNOTATE_MISSING_LINES: true
+          ANNOTATION_TYPE: notice
           VERBOSE: "true"
 
       - name: Store Pull Request comment to be posted
diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py
index 50404ad..0b396d7 100644
--- a/tests/integration/test_github.py
+++ b/tests/integration/test_github.py
@@ -292,3 +292,38 @@ def test_set_output(output_file):
     github.set_output(github_output=output_file, foo=True)
 
     assert output_file.read_text() == "foo=true\n"
+
+
+def test_send_workflow_command(capsys):
+    github.send_workflow_command(
+        command="foo", command_value="bar", file="main.py", line="1", title="someTitle"
+    )
+    output = capsys.readouterr()
+    assert output.out.strip() == "::foo file=main.py,line=1,title=someTitle::bar"
+
+
+def test_send_workflow_command_no_kwargs(capsys):
+    github.send_workflow_command(command="group", command_value="title")
+    output = capsys.readouterr()
+    assert output.out.strip() == "::group::title"
+
+
+def test_create_missing_coverage_annotation(capsys):
+    github.create_missing_coverage_annotation(
+        annotation_type="warning", file="test.py", line=42
+    )
+    output = capsys.readouterr()
+    assert (
+        output.out.strip()
+        == "::warning file=test.py,line=42::This line has no coverage"
+    )
+
+
+def test_create_missing_coverage_annotation__annotation_type(capsys):
+    github.create_missing_coverage_annotation(
+        annotation_type="error", file="test.py", line=42
+    )
+    output = capsys.readouterr()
+    assert (
+        output.out.strip() == "::error file=test.py,line=42::This line has no coverage"
+    )
diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py
index 783effe..7cf4e05 100644
--- a/tests/integration/test_main.py
+++ b/tests/integration/test_main.py
@@ -83,7 +83,7 @@ def integration_env(integration_dir, write_file, run_coverage):
 
 
 def test_action__pull_request__store_comment(
-    pull_request_config, session, in_integration_env, output_file
+    pull_request_config, session, in_integration_env, output_file, capsys
 ):
     # No existing badge in this test
     session.register(
@@ -118,6 +118,10 @@ def test_action__pull_request__store_comment(
     )
     assert result == 0
 
+    # Check that no annotations were made
+    output = capsys.readouterr()
+    assert output.out.strip() == ""
+
     comment_file = pathlib.Path("python-coverage-comment-action.txt").read_text()
     assert comment == comment_file
     assert "No coverage data of the default branch was found for comparison" in comment
@@ -230,6 +234,43 @@ def test_action__pull_request__post_comment__no_marker(
     assert get_logs("ERROR", "Marker not found")
 
 
+def test_action__pull_request__annotations(
+    pull_request_config, session, in_integration_env, output_file, capsys
+):
+    # No existing badge in this test
+    session.register(
+        "GET",
+        "/repos/py-cov-action/foobar/contents/data.json",
+    )(status_code=404)
+
+    # Who am I
+    session.register("GET", "/user")(json={"login": "foo"})
+    # Are there already comments
+    session.register("GET", "/repos/py-cov-action/foobar/issues/2/comments")(json=[])
+
+    # Post a new comment
+    session.register(
+        "POST",
+        "/repos/py-cov-action/foobar/issues/2/comments",
+    )(status_code=200)
+
+    result = main.action(
+        config=pull_request_config(
+            GITHUB_OUTPUT=output_file, ANNOTATE_MISSING_LINES=True
+        ),
+        github_session=session,
+        http_session=session,
+        git=None,
+    )
+    expected = """::group::Annotations of lines with missing coverage
+::warning file=foo.py,line=6::This line has no coverage
+::endgroup::"""
+    output = capsys.readouterr()
+
+    assert result == 0
+    assert output.out.strip() == expected
+
+
 def test_action__pull_request__post_comment__template_error(
     pull_request_config, session, in_integration_env, get_logs
 ):
diff --git a/tests/unit/test_annotations.py b/tests/unit/test_annotations.py
new file mode 100644
index 0000000..306bdca
--- /dev/null
+++ b/tests/unit/test_annotations.py
@@ -0,0 +1,31 @@
+from coverage_comment import annotations
+
+
+def test_annotations(coverage_obj, capsys):
+    annotations.create_pr_annotations(annotation_type="warning", coverage=coverage_obj)
+
+    expected = """::group::Annotations of lines with missing coverage
+::warning file=codebase/code.py,line=7::This line has no coverage
+::warning file=codebase/code.py,line=9::This line has no coverage
+::endgroup::"""
+    output = capsys.readouterr()
+    assert output.out.strip() == expected
+
+
+def test_annotations_several_files(coverage_obj_many_missing_lines, capsys):
+    annotations.create_pr_annotations(
+        annotation_type="notice", coverage=coverage_obj_many_missing_lines
+    )
+
+    expected = """::group::Annotations of lines with missing coverage
+::notice file=codebase/main.py,line=3::This line has no coverage
+::notice file=codebase/main.py,line=7::This line has no coverage
+::notice file=codebase/main.py,line=13::This line has no coverage
+::notice file=codebase/main.py,line=21::This line has no coverage
+::notice file=codebase/main.py,line=123::This line has no coverage
+::notice file=codebase/caller.py,line=13::This line has no coverage
+::notice file=codebase/caller.py,line=21::This line has no coverage
+::notice file=codebase/caller.py,line=212::This line has no coverage
+::endgroup::"""
+    output = capsys.readouterr()
+    assert output.out.strip() == expected
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index e482a28..a5dd326 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -38,6 +38,8 @@ def test_config__from_environ__ok():
             "MINIMUM_GREEN": "90",
             "MINIMUM_ORANGE": "50.8",
             "MERGE_COVERAGE_FILES": "true",
+            "ANNOTATE_MISSING_LINES": "false",
+            "ANNOTATION_TYPE": "error",
             "VERBOSE": "false",
             "FORCE_WORKFLOW_RUN": "false",
         }
@@ -56,6 +58,8 @@ def test_config__from_environ__ok():
         MINIMUM_GREEN=90.0,
         MINIMUM_ORANGE=50.8,
         MERGE_COVERAGE_FILES=True,
+        ANNOTATE_MISSING_LINES=False,
+        ANNOTATION_TYPE="error",
         VERBOSE=False,
         FORCE_WORKFLOW_RUN=False,
     )
@@ -102,6 +106,11 @@ def test_config__from_environ__error():
         settings.Config.from_environ({"COMMENT_FILENAME": "/a"})
 
 
+def test_config__invalid_annotation_type():
+    with pytest.raises(settings.InvalidAnnotationType):
+        settings.Config.from_environ({"ANNOTATION_TYPE": "foo"})
+
+
 @pytest.mark.parametrize(
     "input, output",
     [