Repository: py-cov-action/python-coverage-comment-action
Pull request #135 (2023-02-05 17:09:33)

Improve Logging: use GHA workflow commands
Multiple things appear in this PR, please feel free to request if you're more comfortable if I split it into multiple PRs

- First, I split the method for generating workflow command strings so that we decouple generating the string and printing it. 
- Rename on_coverage_branch > checked_out_branch: kind of unrelated change. I had originally more on there but I decided not to keep it. This one is an isolated change and it annoys me every time I see this function name
- Add a GitHub formatter that formats logs the GitHub way: that's the main thing of the PR: making it so that `logger.error`displays a proper error in the GHA output.
- Deprecate VERBOSE setting: The correct way to add logging to a GitHub Action should be to do it the GitHub way (I've added `ACTIONS_STEP_DEBUG=true` in this repo btw)
- If exception bubbles up to the top, log it properly: While we're at it, if the action fails with an exception, at least make it a pretty one.
<img width="705" alt="Capture d’écran 2023-02-05 à 18 16 10" src="https://user-images.githubusercontent.com/1457576/216834026-6db3063d-b34b-4cbe-8c52-4710ca96f86c.png">


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 12610ad..047af7b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,7 +53,6 @@ jobs:
         with:
           GITHUB_TOKEN: ${{ github.token }}
           ANNOTATE_MISSING_LINES: true
-          VERBOSE: "true"
 
       - name: Store Pull Request comment to be posted
         uses: actions/upload-artifact@v3
diff --git a/README.md b/README.md
index 42bf448..3ed4673 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,9 @@
 
 ## Presentation
 
-Publish diff coverage report as PR comment, and create a coverage badge to
-display on the readme.
+Publish diff coverage report as PR comment, create a coverage badge to
+display on the readme, and a browsable HTML coverage report (hosted in a dedicated
+branch of the repository)
 
 [See this action in action](https://github.com/py-cov-action/python-coverage-comment-action-v3-example)
 
@@ -39,6 +40,8 @@ These files include:
   repository is public to customize the look of your badge
 - Another `json` file used internally by the action to report on coverage
   evolution (does a PR make the coverage go up or down?)
+- A short file-by-file coverage report embedded directy into the branch's README
+- The full HTML coverage report and links to make this report browsable
 
 See [an example](https://github.com/py-cov-action/python-coverage-comment-action-v3-example)
 
@@ -261,9 +264,6 @@ jobs:
     # 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
-
     # Name of the artifact in which the body of the comment to post on the PR is stored.
     # You typically don't have to change this unless you're already using this name for something else.
     COMMENT_ARTIFACT_NAME: python-coverage-comment-action
@@ -278,6 +278,9 @@ jobs:
     # Name of the branch in which coverage data will be stored on the repository.
     # Please make sure that this branch is not protected.
     COVERAGE_DATA_BRANCH: python-coverage-comment-action-data
+
+    # Deprecated, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging
+    VERBOSE: false
 ```
 
 ## Overriding the template
diff --git a/action.yml b/action.yml
index d17aa1a..a4d4784 100644
--- a/action.yml
+++ b/action.yml
@@ -67,7 +67,7 @@ inputs:
     default: warning
   VERBOSE:
     description: >
-      If true, produces more output. Useful for debugging.
+      Deprecated, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging
     default: false
 outputs:
   COMMENT_FILE_WRITTEN:
diff --git a/coverage_comment/communication.py b/coverage_comment/communication.py
index abacd31..6a9b805 100644
--- a/coverage_comment/communication.py
+++ b/coverage_comment/communication.py
@@ -1,97 +1,34 @@
 import pathlib
 
-from coverage_comment import files
-
-README_CONTENTS = """
-# Coverage data
-
-This branch is just here to hold coverage data. It's part of the
-[python-coverage-comment-action](https://github.com/marketplace/actions/python-coverage-comment)
-GitHub Action.
-
-Below are examples of the badges you can use in your main branch `README` file.
-
-### Direct image
-
-[![Coverage badge]({direct_image_url})]({readme_url})
-
-This is the one to use if your repository is private or if you don't want to customize anything.
-""".strip()
-
-PUBLIC_README_CONTENTS = """
-### [Shields.io](https://shields.io) Json Endpoint
-
-[![Coverage badge]({endpoint_image_url})]({readme_url})
-
-Using this one will allow you to [customize](https://shields.io/endpoint) the look of your badge.
-It won't work with private repositories. It won't be refreshed more than once per five minutes.
-
-### [Shields.io](https://shields.io) Dynamic Badge
-
-[![Coverage badge]({dynamic_image_url})]({readme_url})
-
-This one will always be the same color. It won't work for private repos. I'm not even sure why we included it.
-""".strip()
-
-LOG_CONTENTS = """You can use the following URLs to display your badge:
-
-Badge SVG available at:
-    {direct_image_url}""".strip()
-
-PUBLIC_LOG_CONTENTS = """
-Badge from shields endpoint is easier to customize but doesn't work with private repo:
-    {endpoint_image_url}
-
-Badge from shields dynamic url (less useful but you never know):
-    {dynamic_image_url}""".strip()
-
-LOG_CONCLUSION = """
-See more details and ready-to-copy-paste-markdown at {readme_url}""".strip()
+from coverage_comment import files, template
 
 
 def get_readme_and_log(
     image_urls: files.ImageURLs,
     readme_url: str,
+    html_report_url: str,
+    markdown_report: str,
     is_public: bool,
 ) -> tuple[files.FileWithPath, str]:
-    """
-    The text we display in the GitHub Actions Log, and what we write in the
-    branch readme when initializing the coverage data branch is quite similar.
-    This function generate both chunks of text with the hope that they'll stay
-    in sync.
-    """
-    readme_contents_parts = [
-        README_CONTENTS.format(
-            readme_url=readme_url,
-            direct_image_url=image_urls["direct"],
-        )
-    ]
-    if is_public:
-        readme_contents_parts.append(
-            PUBLIC_README_CONTENTS.format(
-                readme_url=readme_url,
-                endpoint_image_url=image_urls["endpoint"],
-                dynamic_image_url=image_urls["dynamic"],
-            )
-        )
+    readme_markdown = template.get_readme_markdown(
+        is_public=is_public,
+        readme_url=readme_url,
+        markdown_report=markdown_report,
+        html_report_url=html_report_url,
+        direct_image_url=image_urls["direct"],
+        endpoint_image_url=image_urls["endpoint"],
+        dynamic_image_url=image_urls["dynamic"],
+    )
+    log_message = template.get_log_message(
+        is_public=is_public,
+        readme_url=readme_url,
+        html_report_url=html_report_url,
+        direct_image_url=image_urls["direct"],
+        endpoint_image_url=image_urls["endpoint"],
+        dynamic_image_url=image_urls["dynamic"],
+    )
     readme = files.FileWithPath(
         path=pathlib.Path("README.md"),
-        contents="\n\n".join(readme_contents_parts),
+        contents=readme_markdown,
     )
-
-    log_message_parts = [
-        LOG_CONTENTS.format(direct_image_url=image_urls["direct"]),
-    ]
-
-    if is_public:
-        public_part = PUBLIC_LOG_CONTENTS.format(
-            endpoint_image_url=image_urls["endpoint"],
-            dynamic_image_url=image_urls["dynamic"],
-        )
-        log_message_parts.append(public_part)
-
-    log_message_parts.append(LOG_CONCLUSION.format(readme_url=readme_url))
-
-    log_message = "\n\n".join(log_message_parts)
-
     return readme, log_message
diff --git a/coverage_comment/coverage.py b/coverage_comment/coverage.py
index e9e259e..bf88879 100644
--- a/coverage_comment/coverage.py
+++ b/coverage_comment/coverage.py
@@ -90,6 +90,14 @@ def get_coverage_info(merge: bool) -> Coverage:
     return extract_info(json.loads(json_coverage))
 
 
+def generate_coverage_html_files() -> None:
+    subprocess.run("coverage", "html", "--skip-empty")
+
+
+def generate_coverage_markdown() -> str:
+    return subprocess.run("coverage", "report", "--format=markdown", "--show-missing")
+
+
 def extract_info(data) -> Coverage:
     """
     {
diff --git a/coverage_comment/files.py b/coverage_comment/files.py
index ef321f4..887bcbf 100644
--- a/coverage_comment/files.py
+++ b/coverage_comment/files.py
@@ -11,7 +11,7 @@ from typing import TypedDict
 
 import httpx
 
-from coverage_comment import badge
+from coverage_comment import badge, coverage
 
 ENDPOINT_PATH = pathlib.Path("endpoint.json")
 DATA_PATH = pathlib.Path("data.json")
@@ -21,7 +21,7 @@ BADGE_PATH = pathlib.Path("badge.svg")
 @dataclasses.dataclass
 class FileWithPath:
     path: pathlib.Path
-    contents: str
+    contents: str | None
 
 
 def compute_files(
@@ -78,3 +78,12 @@ def get_urls(url_getter: Callable) -> ImageURLs:
         "endpoint": badge.get_endpoint_url(endpoint_url=url_getter(path=ENDPOINT_PATH)),
         "dynamic": badge.get_dynamic_url(endpoint_url=url_getter(path=ENDPOINT_PATH)),
     }
+
+
+def get_coverage_html_files() -> FileWithPath:
+    coverage.generate_coverage_html_files()
+    path = pathlib.Path("htmlcov")
+    # Coverage will create a .gitignore if the htmlcov dir didn't exist before,
+    # so we may or may not have one.
+    (path / ".gitignore").unlink(missing_ok=True)
+    return FileWithPath(path=path, contents=None)
diff --git a/coverage_comment/github.py b/coverage_comment/github.py
index 7b9b48e..95a6561 100644
--- a/coverage_comment/github.py
+++ b/coverage_comment/github.py
@@ -173,11 +173,19 @@ 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:
+def get_workflow_command(command: str, command_value: str, **kwargs: str) -> str:
+    """
+    Returns a string that can be printed to send a workflow command
+    https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
+    """
     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)}")
+    return f"::{command}{context}::{escape_data(command_value)}"
+
+
+def send_workflow_command(command: str, command_value: str, **kwargs: str) -> None:
+    print(get_workflow_command(command=command, command_value=command_value, **kwargs))
 
 
 def create_missing_coverage_annotation(annotation_type: str, file: str, line: int):
diff --git a/coverage_comment/log_utils.py b/coverage_comment/log_utils.py
new file mode 100644
index 0000000..7b483e6
--- /dev/null
+++ b/coverage_comment/log_utils.py
@@ -0,0 +1,19 @@
+import logging
+
+from coverage_comment import github
+
+LEVEL_MAPPING = {
+    50: "error",
+    40: "error",
+    30: "warning",
+    20: "notice",
+    10: "debug",
+}
+
+
+class GitHubFormatter(logging.Formatter):
+    def format(self, record):
+        log = super().format(record)
+        level = LEVEL_MAPPING[record.levelno]
+
+        return github.get_workflow_command(command=level, command_value=log)
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index ba0fefa..94f5697 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -12,6 +12,7 @@ from coverage_comment import (
     github,
     github_client,
     log,
+    log_utils,
     settings,
     storage,
     subprocess,
@@ -20,32 +21,37 @@ from coverage_comment import (
 
 
 def main():
-    logging.basicConfig(level="INFO")
-
-    log.info("Starting action")
-    config = settings.Config.from_environ(environ=os.environ)
-    if config.VERBOSE:
-        logging.getLogger().setLevel("DEBUG")
-        log.debug(f"Settings: {config}")
-
-    github_session = httpx.Client(
-        base_url="https://api.github.com",
-        follow_redirects=True,
-        headers={"Authorization": f"token {config.GITHUB_TOKEN}"},
-    )
-    http_session = httpx.Client()
-    git = subprocess.Git()
-    storage.fix_ownership_issues(git=git)
+    try:
+        logging.basicConfig(level="DEBUG")
+        logging.getLogger().handlers[0].formatter = log_utils.GitHubFormatter()
 
-    exit_code = action(
-        config=config,
-        github_session=github_session,
-        http_session=http_session,
-        git=git,
-    )
+        log.info("Starting action")
+        config = settings.Config.from_environ(environ=os.environ)
+
+        github_session = httpx.Client(
+            base_url="https://api.github.com",
+            follow_redirects=True,
+            headers={"Authorization": f"token {config.GITHUB_TOKEN}"},
+        )
+        http_session = httpx.Client()
+        git = subprocess.Git()
+        storage.fix_ownership_issues(git=git)
 
-    log.info("Ending action")
-    sys.exit(exit_code)
+        exit_code = action(
+            config=config,
+            github_session=github_session,
+            http_session=http_session,
+            git=git,
+        )
+
+        log.info("Ending action")
+        sys.exit(exit_code)
+
+    except Exception:
+        log.exception(
+            "Critical error. Please look for open issues or open one in https://github.com/py-cov-action/python-coverage-comment-action/"
+        )
+        sys.exit(1)
 
 
 def action(
@@ -81,7 +87,7 @@ def action(
             )
         else:
             # event_name == "push"
-            return save_badge(
+            return save_coverage_data_files(
                 config=config,
                 coverage=coverage,
                 github_session=github_session,
@@ -119,11 +125,11 @@ def generate_comment(
         previous_coverage = files.parse_datafile(contents=previous_coverage_data_file)
 
     try:
-        comment = template.get_markdown_comment(
+        comment = template.get_comment_markdown(
             coverage=coverage,
             diff_coverage=diff_coverage,
             previous_coverage_rate=previous_coverage,
-            base_template=template.read_template_file(),
+            base_template=template.read_template_file("comment.md.j2"),
             custom_template=config.COMMENT_TEMPLATE,
         )
     except template.MissingMarker:
@@ -234,7 +240,7 @@ def post_comment(config: settings.Config, github_session: httpx.Client) -> int:
     return 0
 
 
-def save_badge(
+def save_coverage_data_files(
     config: settings.Config,
     coverage: coverage_module.Coverage,
     github_session: httpx.Client,
@@ -253,30 +259,44 @@ def save_badge(
         log.info("Skipping badge save as we're not on the default branch")
         return 0
 
-    log.info("Saving coverage files & badge into the repository")
-    files_to_save = files.compute_files(
+    log.info("Computing coverage files & badge")
+    all_files = files.compute_files(
         line_rate=coverage.info.percent_covered,
         minimum_green=config.MINIMUM_GREEN,
         minimum_orange=config.MINIMUM_ORANGE,
         http_session=http_session,
     )
+
     is_public = repo_info.is_public()
+    if is_public:
+        log.info("Generating HTML coverage report")
+        all_files.append(files.get_coverage_html_files())
+
+    markdown_report = coverage_module.generate_coverage_markdown()
+
     url_getter = functools.partial(
-        storage.get_file_url,
+        storage.get_raw_file_url,
         is_public=is_public,
         repository=config.GITHUB_REPOSITORY,
         branch=config.COVERAGE_DATA_BRANCH,
     )
+    readme_url = storage.get_repo_file_url(
+        branch=config.COVERAGE_DATA_BRANCH,
+        repository=config.GITHUB_REPOSITORY,
+    )
+    html_report_url = storage.get_html_report_url(
+        branch=config.COVERAGE_DATA_BRANCH,
+        repository=config.GITHUB_REPOSITORY,
+    )
     readme_file, log_message = communication.get_readme_and_log(
-        readme_url=storage.get_readme_url(
-            branch=config.COVERAGE_DATA_BRANCH,
-            repository=config.GITHUB_REPOSITORY,
-        ),
-        image_urls=files.get_urls(url_getter=url_getter),
         is_public=is_public,
+        readme_url=readme_url,
+        image_urls=files.get_urls(url_getter=url_getter),
+        html_report_url=html_report_url,
+        markdown_report=markdown_report,
     )
     storage.upload_files(
-        files=files_to_save,
+        files=all_files,
         git=git,
         branch=config.COVERAGE_DATA_BRANCH,
         initial_file=readme_file,
diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py
index 6355032..5f4e882 100644
--- a/coverage_comment/settings.py
+++ b/coverage_comment/settings.py
@@ -4,6 +4,8 @@ import inspect
 import pathlib
 from typing import Any
 
+from coverage_comment import log
+
 
 class MissingEnvironmentVariable(Exception):
     pass
@@ -81,7 +83,11 @@ class Config:
 
     @classmethod
     def clean_verbose(cls, value: str) -> bool:
-        return str_to_bool(value)
+        if str_to_bool(value):
+            log.info(
+                "VERBOSE setting is deprecated. For increased debug output, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging"
+            )
+        return False
 
     @classmethod
     def clean_force_workflow_run(cls, value: str) -> bool:
diff --git a/coverage_comment/storage.py b/coverage_comment/storage.py
index 72303c5..cf36874 100644
--- a/coverage_comment/storage.py
+++ b/coverage_comment/storage.py
@@ -44,7 +44,7 @@ def initialize_branch(
 
 
 @contextlib.contextmanager
-def on_coverage_branch(git: subprocess.Git, branch: str):
+def checked_out_branch(git: subprocess.Git, branch: str):
     try:
         current_checkout = git.branch("--show-current").strip()
     except subprocess.SubProcessError:
@@ -89,7 +89,7 @@ def upload_files(
     initial_file : files.FileWithPath
         In case the branch didn't exist, initialize it with this initial file.
     """
-    with on_coverage_branch(git=git, branch=branch) as branch_exists:
+    with checked_out_branch(git=git, branch=branch) as branch_exists:
         if not branch_exists:
             initialize_branch(
                 git=git,
@@ -97,8 +97,10 @@ def upload_files(
                 initial_file=initial_file,
             )
 
-        for file in files:
-            file.path.write_text(file.contents)
+        for file in files + [initial_file]:
+            log.debug(f"Adding {file}")
+            if file.contents is not None:
+                file.path.write_text(file.contents)
             git.add(str(file.path))
 
         try:
@@ -134,7 +136,7 @@ def get_datafile_contents(
     return base64.b64decode(response.content).decode()
 
 
-def get_file_url(
+def get_raw_file_url(
     repository: str,
     branch: str,
     path: pathlib.Path,
@@ -157,8 +159,26 @@ def get_file_url(
     # seconds.
 
 
-def get_readme_url(repository: str, branch: str) -> str:
-    return f"https://github.com/{repository}/tree/{branch}"
+def get_repo_file_url(repository: str, branch: str, path: str = "/") -> str:
+    """
+    Computes the GitHub Web UI URL for a given path:
+    If the path is empty or ends with a slash, it will be interpreted as a folder,
+    so the URL will point to the page listing files and displaying the README.
+    Otherwise, the URL will point to the page displaying the file contents within
+    the UI.
+    Leading and trailing slashes in path are removed from the final URL.
+    """
+    # See test_get_repo_file_url for precise specifications
+    path = "/" + path.lstrip("/")
+    part = "tree" if path.endswith("/") else "blob"
+    return f"https://github.com/{repository}/{part}/{branch}{path}".rstrip("/")
+
+
+def get_html_report_url(repository: str, branch: str) -> str:
+    readme_url = get_repo_file_url(
+        repository=repository, branch=branch, path="/htmlcov/index.html"
+    )
+    return f"https://htmlpreview.github.io/?{readme_url}"
 
 
 def fix_ownership_issues(git: subprocess.Git):
diff --git a/coverage_comment/template.py b/coverage_comment/template.py
index 5001902..79004e0 100644
--- a/coverage_comment/template.py
+++ b/coverage_comment/template.py
@@ -39,7 +39,7 @@ class TemplateError(Exception):
     pass
 
 
-def get_markdown_comment(
+def get_comment_markdown(
     coverage: coverage_module.Coverage,
     diff_coverage: coverage_module.DiffCoverage,
     previous_coverage_rate: decimal.Decimal | None,
@@ -66,8 +66,52 @@ def get_markdown_comment(
     return comment
 
 
-def read_template_file() -> str:
-    return resources.read_text("coverage_comment", "default.md.j2")
+def get_readme_markdown(
+    is_public: bool,
+    readme_url: str,
+    markdown_report: str,
+    direct_image_url: str,
+    html_report_url: str | None,
+    dynamic_image_url: str | None,
+    endpoint_image_url: str | None,
+):
+    env = SandboxedEnvironment()
+    template = jinja2.Template(read_template_file("readme.md.j2"))
+    return env.get_template(template).render(
+        is_public=is_public,
+        readme_url=readme_url,
+        markdown_report=markdown_report,
+        direct_image_url=direct_image_url,
+        html_report_url=html_report_url,
+        dynamic_image_url=dynamic_image_url,
+        endpoint_image_url=endpoint_image_url,
+    )
+
+
+def get_log_message(
+    is_public: bool,
+    readme_url: str,
+    direct_image_url: str,
+    html_report_url: str | None,
+    dynamic_image_url: str | None,
+    endpoint_image_url: str | None,
+):
+    env = SandboxedEnvironment()
+    template = jinja2.Template(read_template_file("log.txt.j2"))
+    return env.get_template(template).render(
+        is_public=is_public,
+        html_report_url=html_report_url,
+        direct_image_url=direct_image_url,
+        endpoint_image_url=endpoint_image_url,
+        dynamic_image_url=dynamic_image_url,
+        readme_url=readme_url,
+    )
+
+
+def read_template_file(template: str) -> str:
+    return (
+        resources.files("coverage_comment") / "template_files" / template
+    ).read_text()
 
 
 def pct(val: decimal.Decimal | float) -> str:
diff --git a/coverage_comment/default.md.j2 b/coverage_comment/template_files/comment.md.j2
similarity index 100%
rename from coverage_comment/default.md.j2
rename to coverage_comment/template_files/comment.md.j2
diff --git a/coverage_comment/template_files/log.txt.j2 b/coverage_comment/template_files/log.txt.j2
new file mode 100644
index 0000000..0b69f09
--- /dev/null
+++ b/coverage_comment/template_files/log.txt.j2
@@ -0,0 +1,20 @@
+{% if is_public -%}
+You can browse the full coverage report at:
+    {{ html_report_url }}
+
+{% endif -%}
+You can use the following URLs to display your badge:
+
+- Badge SVG available at:
+    {{ direct_image_url }}
+
+{% if is_public -%}
+- Badge from shields endpoint is easier to customize but doesn't work with private repo:
+    {{ endpoint_image_url }}
+
+- Badge from shields dynamic url (less useful but you never know):
+    {{ dynamic_image_url }}
+
+{% endif -%}
+See more details and ready-to-copy-paste-markdown at:
+    {{ readme_url }}
diff --git a/coverage_comment/template_files/readme.md.j2 b/coverage_comment/template_files/readme.md.j2
new file mode 100644
index 0000000..8f85075
--- /dev/null
+++ b/coverage_comment/template_files/readme.md.j2
@@ -0,0 +1,39 @@
+# Repository Coverage
+
+{% if is_public -%}
+[Full report]({{ html_report_url }})
+{%- endif %}
+
+{{ markdown_report }}
+
+## Setup coverage badge
+
+Below are examples of the badges you can use in your main branch `README` file.
+
+### Direct image
+
+[![Coverage badge]({{ direct_image_url }})]({{ html_report_url if is_public else readme_url }})
+
+This is the one to use if your repository is private or if you don't want to customize anything.
+
+{% if is_public -%}
+### [Shields.io](https://shields.io) Json Endpoint
+
+[![Coverage badge]({{ endpoint_image_url }})]({{html_report_url}})
+
+Using this one will allow you to [customize](https://shields.io/endpoint) the look of your badge.
+It won't work with private repositories. It won't be refreshed more than once per five minutes.
+
+### [Shields.io](https://shields.io) Dynamic Badge
+
+[![Coverage badge]({{ dynamic_image_url }})]({{ html_report_url }})
+
+This one will always be the same color. It won't work for private repos. I'm not even sure why we included it.
+{%- endif %}
+
+## What is that?
+
+This branch is part of the
+[python-coverage-comment-action](https://github.com/marketplace/actions/python-coverage-comment)
+GitHub Action. All the files in this branch are automatically generated and may be
+overwritten at any moment.
diff --git a/dev-env b/dev-env
index 826d5cc..28ad73b 100755
--- a/dev-env
+++ b/dev-env
@@ -131,7 +131,6 @@ function run(){
 event push
 ref branch
 run $(gh run list --limit 1 --json databaseId --jq='.[].databaseId')
-export VERBOSE=true
 
 function help(){
 
diff --git a/poetry.lock b/poetry.lock
index 4d8911a..c92703a 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -544,14 +544,13 @@ reports = ["lxml"]
 
 [[package]]
 name = "mypy-extensions"
-version = "1.0.0"
-description = "Type system extensions for programs checked with the mypy type checker."
+version = "0.4.4"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=2.7"
 files = [
-    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
-    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+    {file = "mypy_extensions-0.4.4.tar.gz", hash = "sha256:c8b707883a96efe9b4bb3aaf0dcc07e7e217d7d8368eec4db4049ee9e142f4fd"},
 ]
 
 [[package]]


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 411c38c..6a3fa9a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,7 +22,6 @@ def base_config():
             "GITHUB_REPOSITORY": "py-cov-action/foobar",
             # Action settings
             "MERGE_COVERAGE_FILES": True,
-            "VERBOSE": False,
         }
         return settings.Config(**(defaults | kwargs))
 
diff --git a/tests/end_to_end/conftest.py b/tests/end_to_end/conftest.py
index 761b801..bc3a6d1 100644
--- a/tests/end_to_end/conftest.py
+++ b/tests/end_to_end/conftest.py
@@ -252,6 +252,15 @@ def gh_create_repo(is_failed, gh_delete_repo, gh_me, git_repo, repo_name):
             f"--source={git_repo}",
             *args,
         )
+        # Someday, we may be able to change that to a variable instead of a secret
+        # https://github.com/cli/cli/pull/6928
+        gh_me(
+            "secret",
+            "set",
+            "--app=actions",
+            "ACTIONS_STEP_DEBUG",
+            "--body=true",
+        )
         return git_repo
 
     yield f
diff --git a/tests/end_to_end/repo/.github/workflows/ci.yml b/tests/end_to_end/repo/.github/workflows/ci.yml
index a84cf7b..e1aa5c3 100644
--- a/tests/end_to_end/repo/.github/workflows/ci.yml
+++ b/tests/end_to_end/repo/.github/workflows/ci.yml
@@ -36,7 +36,6 @@ jobs:
           GITHUB_TOKEN: ${{ github.token }}
           ANNOTATE_MISSING_LINES: true
           ANNOTATION_TYPE: notice
-          VERBOSE: "true"
 
       - name: Store Pull Request comment to be posted
         uses: actions/upload-artifact@v3
diff --git a/tests/end_to_end/test_all.py b/tests/end_to_end/test_all.py
index e92de73..b69461a 100644
--- a/tests/end_to_end/test_all.py
+++ b/tests/end_to_end/test_all.py
@@ -50,27 +50,30 @@ def test_public_repo(
     # The easiest way to check the links is to assume there will be no other
     # line with a link prefixed by 4 spaces. If at some point there is, we'll
     # change the test.
-    links = [line.strip().split()[-1] for line in log_lines if "    https://" in line]
-    assert len(links) == 3
+    links = {line.strip().split()[-1] for line in log_lines if "    https://" in line}
+    # - html report
+    # - badge 1, 2 and 3
+    # - coverage branch readme url
+    assert len(links) == 5
 
     client = httpx.Client()
 
-    # Check that all 3 links are valid and lead to a 200
+    # Check that all 5 links are valid and lead to a 200
     # It's made this way to avoid hardcoding links in the test, because I assume
     # they'll be evolving.
+    number_of_svgs = 0
     for link in links:
         response = client.get(link)
         response.raise_for_status()
-        assert response.text.startswith("<svg")
+        number_of_svgs += int(response.text.startswith("<svg"))
+
+    assert number_of_svgs == 3
 
     # Check that logs point to the branch that has the readme file.
     data_branch_url = (
         f"https://github.com/{repo_full_name}/tree/python-coverage-comment-action-data"
     )
-    assert data_branch_url in logs
-
-    # Also, at this point, the branch should really exist.
-    client.get(data_branch_url).raise_for_status()
+    assert data_branch_url in links
 
     # Time to check the Readme contents
     raw_url_prefix = (
@@ -82,7 +85,7 @@ def test_public_repo(
     response.raise_for_status()
     # And all previously found links should be present
     readme = response.text
-    for link in links:
+    for link in links - {data_branch_url}:
         assert link in readme
 
     # And while we're at it, there are 2 other files we want to check in this
diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py
index d466b6d..f594fa5 100644
--- a/tests/integration/test_github.py
+++ b/tests/integration/test_github.py
@@ -291,6 +291,20 @@ def test_set_output(output_file):
     assert output_file.read_text() == "foo=true\n"
 
 
+def test_get_workflow_command():
+    output = github.get_workflow_command(
+        command="foo", command_value="bar", file="main.py", line="1", title="someTitle"
+    )
+
+    assert output == "::foo file=main.py,line=1,title=someTitle::bar"
+
+
+def test_get_workflow_command_no_kwargs():
+    output = github.get_workflow_command(command="group", command_value="title")
+
+    assert output == "::group::title"
+
+
 def test_send_workflow_command(capsys):
     github.send_workflow_command(
         command="foo", command_value="bar", file="main.py", line="1", title="someTitle"
@@ -299,12 +313,6 @@ def test_send_workflow_command(capsys):
     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
diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py
index c43fddc..99df984 100644
--- a/tests/integration/test_main.py
+++ b/tests/integration/test_main.py
@@ -323,6 +323,8 @@ def test_action__push__default_branch(
     git.register("git add endpoint.json")()
     git.register("git add data.json")()
     git.register("git add badge.svg")()
+    git.register("git add htmlcov")()
+    git.register("git add README.md")()
     git.register("git diff --staged --exit-code")(exit_code=1)
     git.register("git commit --message Update badge")()
     git.register("git push origin python-coverage-comment-action-data")()
@@ -337,21 +339,70 @@ def test_action__push__default_branch(
     assert result == 0
 
     assert not get_logs("INFO", "Skipping badge")
-    assert get_logs("INFO", "Saving coverage files & badge into the repository")
+    assert get_logs("INFO", "Saving coverage files")
 
     log = get_logs("INFO", "Badge SVG available at")[0]
-    expected = """You can use the following URLs to display your badge:
+    expected = """You can browse the full coverage report at:
+    https://htmlpreview.github.io/?https://github.com/py-cov-action/foobar/blob/python-coverage-comment-action-data/htmlcov/index.html
+
+You can use the following URLs to display your badge:
 
-Badge SVG available at:
+- Badge SVG available at:
     https://raw.githubusercontent.com/py-cov-action/foobar/python-coverage-comment-action-data/badge.svg
 
-Badge from shields endpoint is easier to customize but doesn't work with private repo:
+- Badge from shields endpoint is easier to customize but doesn't work with private repo:
     https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/py-cov-action/foobar/python-coverage-comment-action-data/endpoint.json
 
-Badge from shields dynamic url (less useful but you never know):
+- Badge from shields dynamic url (less useful but you never know):
     https://img.shields.io/badge/dynamic/json?color=brightgreen&label=coverage&query=%24.message&url=https%3A%2F%2Fraw.githubusercontent.com%2Fpy-cov-action%2Ffoobar%2Fpython-coverage-comment-action-data%2Fendpoint.json
 
-See more details and ready-to-copy-paste-markdown at https://github.com/py-cov-action/foobar/tree/python-coverage-comment-action-data"""
+See more details and ready-to-copy-paste-markdown at:
+    https://github.com/py-cov-action/foobar/tree/python-coverage-comment-action-data"""
+    assert log == expected
+
+
+def test_action__push__default_branch__private(
+    push_config, session, in_integration_env, get_logs, git
+):
+    session.register("GET", "/repos/py-cov-action/foobar")(
+        json={"default_branch": "main", "visibility": "private"}
+    )
+    session.register(
+        "GET",
+        "https://img.shields.io/static/v1?label=Coverage&message=85%25&color=orange",
+    )(text="<this is a svg badge>")
+
+    git.register("git branch --show-current")(stdout="foo")
+    git.register("git fetch")()
+    git.register("git checkout python-coverage-comment-action-data")()
+    git.register("git add endpoint.json")()
+    git.register("git add data.json")()
+    git.register("git add badge.svg")()
+    git.register("git add README.md")()
+    git.register("git diff --staged --exit-code")(exit_code=1)
+    git.register("git commit --message Update badge")()
+    git.register("git push origin python-coverage-comment-action-data")()
+    git.register("git checkout foo")()
+
+    result = main.action(
+        config=push_config(),
+        github_session=session,
+        http_session=session,
+        git=git,
+    )
+    assert result == 0
+
+    assert not get_logs("INFO", "Skipping badge")
+    assert get_logs("INFO", "Saving coverage files")
+
+    log = get_logs("INFO", "Badge SVG available at")[0]
+    expected = """You can use the following URLs to display your badge:
+
+- Badge SVG available at:
+    https://github.com/py-cov-action/foobar/raw/python-coverage-comment-action-data/badge.svg
+
+See more details and ready-to-copy-paste-markdown at:
+    https://github.com/py-cov-action/foobar/tree/python-coverage-comment-action-data"""
     assert log == expected
 
 
diff --git a/tests/unit/test_communication.py b/tests/unit/test_communication.py
index ec8243b..7fdb2b0 100644
--- a/tests/unit/test_communication.py
+++ b/tests/unit/test_communication.py
@@ -1,36 +1,69 @@
-import pytest
-
 from coverage_comment import communication
 
 
-@pytest.mark.parametrize("is_public", [True, False])
-def test_get_readme_and_log(is_public):
+def test_get_readme_and_log__public():
+    readme_file, log = communication.get_readme_and_log(
+        is_public=True,
+        image_urls={
+            "direct": "https://a",
+            "endpoint": "https://b",
+            "dynamic": "https://c",
+        },
+        readme_url="https://readme",
+        html_report_url="https://html_report",
+        markdown_report="**Hello report!**",
+    )
+
+    readme = readme_file.contents
+
+    assert str(readme_file.path) == "README.md"
+
+    assert "# Repository Coverage" in readme
+
+    assert "[![Coverage badge](https://a)](https://html_report)" in readme
+
+    assert "[![Coverage badge](https://b)](https://html_report)" in readme
+
+    assert "[![Coverage badge](https://c)](https://html_report)" in readme
+
+    assert "https://a" in log
+
+    assert "https://readme" in log
+
+    assert "https://b" in log
+
+    assert "https://c" in log
+
+
+def test_get_readme_and_log__private():
     readme_file, log = communication.get_readme_and_log(
+        is_public=False,
         image_urls={
             "direct": "https://a",
             "endpoint": "https://b",
             "dynamic": "https://c",
         },
         readme_url="https://readme",
-        is_public=is_public,
+        html_report_url="https://html_report",
+        markdown_report="**Hello report!**",
     )
 
     readme = readme_file.contents
 
     assert str(readme_file.path) == "README.md"
 
-    assert "# Coverage data" in readme
+    assert "# Repository Coverage" in readme
 
     assert "[![Coverage badge](https://a)](https://readme)" in readme
 
-    assert ("[![Coverage badge](https://b)](https://readme)" in readme) is (is_public)
+    assert "https://b" not in readme
 
-    assert ("[![Coverage badge](https://c)](https://readme)" in readme) is (is_public)
+    assert "https://c" not in readme
 
     assert "https://a" in log
 
     assert "https://readme" in log
 
-    assert ("https://b" in log) is (is_public)
+    assert "https://b" not in log
 
-    assert ("https://c" in log) is (is_public)
+    assert "https://c" not in log
diff --git a/tests/unit/test_coverage.py b/tests/unit/test_coverage.py
index 5c6e1f7..c1373dc 100644
--- a/tests/unit/test_coverage.py
+++ b/tests/unit/test_coverage.py
@@ -68,3 +68,27 @@ def test_get_coverage_info__error_no_source(mocker, get_logs):
         coverage.get_coverage_info(merge=False)
 
     assert get_logs("ERROR", "Cannot read")
+
+
+def test_generate_coverage_html_files(mocker):
+    run = mocker.patch(
+        "coverage_comment.subprocess.run",
+    )
+
+    coverage.generate_coverage_html_files()
+
+    assert run.call_args_list == [
+        mocker.call("coverage", "html", "--skip-empty"),
+    ]
+
+
+def test_generate_coverage_markdown(mocker):
+    run = mocker.patch("coverage_comment.subprocess.run", return_value="foo")
+
+    result = coverage.generate_coverage_markdown()
+
+    assert run.call_args_list == [
+        mocker.call("coverage", "report", "--format=markdown", "--show-missing"),
+    ]
+
+    assert result == "foo"
diff --git a/tests/unit/test_files.py b/tests/unit/test_files.py
index 152314d..319072a 100644
--- a/tests/unit/test_files.py
+++ b/tests/unit/test_files.py
@@ -52,3 +52,18 @@ def test_get_urls():
         "dynamic": "https://img.shields.io/badge/dynamic/json?color=brightgreen&label=coverage&query=%24.message&url=https%3A%2F%2Fendpoint.json",
         "endpoint": "https://img.shields.io/endpoint?url=https://endpoint.json",
     }
+
+
+def test_get_coverage_html_files(mocker, in_tmp_path):
+    gen = mocker.patch("coverage_comment.coverage.generate_coverage_html_files")
+    cov = in_tmp_path / "htmlcov"
+    cov.mkdir()
+    gitignore = cov / ".gitignore"
+    gitignore.touch()
+
+    assert files.get_coverage_html_files() == files.FileWithPath(
+        path=pathlib.Path("htmlcov"), contents=None
+    )
+
+    assert gen.called is True
+    assert not gitignore.exists()
diff --git a/tests/unit/test_log_utils.py b/tests/unit/test_log_utils.py
new file mode 100644
index 0000000..228f480
--- /dev/null
+++ b/tests/unit/test_log_utils.py
@@ -0,0 +1,50 @@
+import logging
+import re
+
+from coverage_comment import log_utils
+
+
+def test_level_mapping__all_supported():
+    # TODO Python 3.11: replace logging._levelToName with
+    # logging.getLevelNamesMapping()
+    ignored = {
+        logging.getLevelName("NOTSET"),
+        logging.getLevelName("TRACE"),
+    }
+    assert set(log_utils.LEVEL_MAPPING) == set(logging._levelToName) - ignored
+
+
+def test__github_formatter():
+    logs = []
+
+    class TestHandler(logging.Handler):
+        def emit(self, record):
+            logs.append(self.format(record))
+
+    logger = logging.Logger("test", level="DEBUG")
+    handler = TestHandler()
+    handler.setFormatter(log_utils.GitHubFormatter())
+    logger.addHandler(handler)
+
+    logger.debug("a debug message")
+    logger.info("a notice message")
+    logger.warning("a warning message")
+    logger.error("an error message")
+    logger.critical("an error message")
+    try:
+        0 / 0
+    except Exception:
+        logger.exception("an exception")
+
+    logs = "\n".join(logs)
+    logs = re.sub(r"""File ".+", line \d+""", """File "foo.py", line 42""", logs)
+
+    expected = """
+::debug::a debug message
+::notice::a notice message
+::warning::a warning message
+::error::an error message
+::error::an error message
+::error::an exception%0ATraceback (most recent call last):%0A  File "foo.py", line 42, in test__github_formatter%0A    0 / 0%0AZeroDivisionError: division by zero""".strip()
+
+    assert logs == expected
diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py
index dff0fc2..496cf58 100644
--- a/tests/unit/test_main.py
+++ b/tests/unit/test_main.py
@@ -1,4 +1,3 @@
-import logging
 import os
 
 import httpx
@@ -29,7 +28,7 @@ def test_post_comment__no_run_id(workflow_run_config, get_logs):
 
 
 def test_main(mocker, get_logs):
-    # This test is a mock frienzy. The idea is that all the things that are hard
+    # This test is a mock festival. The idea is that all the things that are hard
     # to simulate without mocks have been pushed up the stack up to this function
     # so this is THE place where we have no choice but to mock.
     # We could also accept not to test this function but if we've come this
@@ -61,15 +60,12 @@ def test_main(mocker, get_logs):
     assert get_logs("INFO", "Ending action")
 
 
-def test_main__verbose(mocker, get_logs, caplog):
-    # This test is a mock frienzy. The idea is that all the things that are hard
-    # to simulate without mocks have been pushed up the stack up to this function
-    # so this is THE place where we have no choice but to mock.
-    # We could also accept not to test this function but if we've come this
-    # far and have 98% coverage, we can as well have 100%.
-
-    mocker.patch("sys.exit")
-    mocker.patch("coverage_comment.main.action")
+def test_main__exception(mocker, get_logs):
+    # This test simulates an exception in the main part of the action. This should be catched and logged.
+    exit = mocker.patch("sys.exit")
+    mocker.patch(
+        "coverage_comment.main.action", side_effect=Exception("Mocked exception")
+    )
 
     os.environ.update(
         {
@@ -79,10 +75,9 @@ def test_main__verbose(mocker, get_logs, caplog):
             "GITHUB_TOKEN": "token",
             "GITHUB_BASE_REF": "",
             "GITHUB_EVENT_NAME": "push",
-            "VERBOSE": "true",
         }
     )
     main.main()
+    exit.assert_called_with(1)
 
-    assert get_logs("DEBUG", "Settings: Config(")
-    assert logging.getLevelName(logging.getLogger().level) == "DEBUG"
+    assert get_logs("ERROR", "Critical error")
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index 4dbaeb5..96f7544 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -66,6 +66,29 @@ def test_config__from_environ__ok():
     )
 
 
+def test_config__verbose_deprecated(get_logs):
+    assert settings.Config.from_environ(
+        {
+            "GITHUB_BASE_REF": "master",
+            "GITHUB_TOKEN": "foo",
+            "GITHUB_REPOSITORY": "owner/repo",
+            "GITHUB_REF": "master",
+            "GITHUB_EVENT_NAME": "pull",
+            "GITHUB_PR_RUN_ID": "123",
+            "VERBOSE": "true",
+        }
+    ) == settings.Config(
+        GITHUB_BASE_REF="master",
+        GITHUB_TOKEN="foo",
+        GITHUB_REPOSITORY="owner/repo",
+        GITHUB_REF="master",
+        GITHUB_EVENT_NAME="pull",
+        GITHUB_PR_RUN_ID=123,
+        VERBOSE=False,
+    )
+    assert get_logs("INFO", "VERBOSE setting is deprecated")
+
+
 @pytest.fixture
 def config():
     defaults = {
@@ -81,7 +104,6 @@ def config():
         "MINIMUM_GREEN": decimal.Decimal("90"),
         "MINIMUM_ORANGE": decimal.Decimal("50.8"),
         "MERGE_COVERAGE_FILES": True,
-        "VERBOSE": False,
     }
 
     def _(**kwargs):
diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py
index 83eb0f5..00d1876 100644
--- a/tests/unit/test_storage.py
+++ b/tests/unit/test_storage.py
@@ -30,33 +30,33 @@ def test_initialize_branch(git, tmp_path):
     assert readme_path.read_text() == "bar"
 
 
-def test_on_coverage_branch(git):
+def test_checked_out_branch(git):
     git.register("git branch --show-current")(stdout="bar")
     git.register("git fetch")()
     git.register("git checkout foo")()
 
-    with storage.on_coverage_branch(git=git, branch="foo") as exists:
+    with storage.checked_out_branch(git=git, branch="foo") as exists:
         assert exists is True
         git.register("git checkout bar")()
 
 
-def test_on_coverage_branch__detached_head(git):
+def test_checked_out_branch__detached_head(git):
     git.register("git branch --show-current")(exit_code=1)
     git.register("git rev-parse --short HEAD")(stdout="123abc")
     git.register("git fetch")()
     git.register("git checkout foo")()
 
-    with storage.on_coverage_branch(git=git, branch="foo") as exists:
+    with storage.checked_out_branch(git=git, branch="foo") as exists:
         assert exists is True
         git.register("git checkout 123abc")()
 
 
-def test_on_coverage_branch__branch_doesnt_exist(git):
+def test_checked_out_branch__branch_doesnt_exist(git):
     git.register("git branch --show-current")(stdout="bar")
     git.register("git fetch")()
     git.register("git checkout foo")(exit_code=1)
 
-    with storage.on_coverage_branch(git=git, branch="foo") as exists:
+    with storage.checked_out_branch(git=git, branch="foo") as exists:
         assert exists is False
         git.register("git checkout bar")()
 
@@ -74,9 +74,10 @@ def test_upload_files(git, in_tmp_path, branch_exists, has_diff):
     files_to_save = [
         files.FileWithPath(path=pathlib.Path("a.txt"), contents="a"),
         files.FileWithPath(path=pathlib.Path("b.txt"), contents="b"),
+        files.FileWithPath(path=pathlib.Path("c.txt"), contents=None),
     ]
 
-    # on_coverage_branch
+    # checked_out_branch
     git.register("git branch --show-current")(stdout="bar")
     git.register("git fetch")()
     git.register("git checkout foo")(exit_code=0 if branch_exists else 1)
@@ -103,6 +104,8 @@ def test_upload_files(git, in_tmp_path, branch_exists, has_diff):
     # upload_files
     git.register(f"git add {files_to_save[0].path}")()
     git.register(f"git add {files_to_save[1].path}")()
+    git.register(f"git add {files_to_save[2].path}")()
+    git.register("git add readme.txt")()
     git.register("git diff --staged --exit-code")(exit_code=1 if has_diff else 0)
 
     if has_diff:
@@ -119,7 +122,7 @@ def test_upload_files(git, in_tmp_path, branch_exists, has_diff):
         )()
         git.register("git push origin foo")()
 
-    # __exit__ of on_coverage_branch
+    # __exit__ of checked_out_branch
     git.register("git checkout bar")()
 
     storage.upload_files(
@@ -131,6 +134,7 @@ def test_upload_files(git, in_tmp_path, branch_exists, has_diff):
 
     assert files_to_save[0].path.read_text() == files_to_save[0].contents
     assert files_to_save[1].path.read_text() == files_to_save[1].contents
+    assert not files_to_save[2].path.exists()
 
 
 def test_get_datafile_contents__not_found(gh, session):
@@ -167,8 +171,8 @@ def test_get_datafile_contents(gh, session):
         (True, "https://raw.githubusercontent.com/foo/bar/baz/qux"),
     ],
 )
-def test_get_file_url(is_public, expected):
-    result = storage.get_file_url(
+def test_get_raw_file_url(is_public, expected):
+    result = storage.get_raw_file_url(
         repository="foo/bar",
         branch="baz",
         path=pathlib.Path("qux"),
@@ -177,7 +181,30 @@ def test_get_file_url(is_public, expected):
     assert result == expected
 
 
-def test_get_readme_url():
-    result = storage.get_readme_url(repository="foo/bar", branch="baz")
+@pytest.mark.parametrize(
+    "path, expected",
+    [
+        ("", "https://github.com/foo/bar/tree/baz"),
+        ("/", "https://github.com/foo/bar/tree/baz"),
+        ("qux", "https://github.com/foo/bar/blob/baz/qux"),  # blob
+        ("qux/", "https://github.com/foo/bar/tree/baz/qux"),
+        ("/qux", "https://github.com/foo/bar/blob/baz/qux"),  # blob
+        ("/qux/", "https://github.com/foo/bar/tree/baz/qux"),
+    ],
+)
+def test_get_repo_file_url(path, expected):
+    result = storage.get_repo_file_url(repository="foo/bar", branch="baz", path=path)
+
+    assert result == expected
+
+
+def test_get_repo_file_url__no_path():
+    result = storage.get_repo_file_url(repository="foo/bar", branch="baz")
 
     assert result == "https://github.com/foo/bar/tree/baz"
+
+
+def test_get_html_report_url():
+    result = storage.get_html_report_url(repository="foo/bar", branch="baz")
+    expected = "https://htmlpreview.github.io/?https://github.com/foo/bar/blob/baz/htmlcov/index.html"
+    assert result == expected
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index fde6466..9dfed3a 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -6,9 +6,9 @@ import pytest
 from coverage_comment import coverage, template
 
 
-def test_get_markdown_comment(coverage_obj, diff_coverage_obj):
+def test_get_comment_markdown(coverage_obj, diff_coverage_obj):
     result = (
-        template.get_markdown_comment(
+        template.get_comment_markdown(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
@@ -39,11 +39,11 @@ def test_get_markdown_comment(coverage_obj, diff_coverage_obj):
 
 
 def test_template(coverage_obj, diff_coverage_obj):
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=coverage_obj,
         diff_coverage=diff_coverage_obj,
         previous_coverage_rate=decimal.Decimal("0.92"),
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
         custom_template="""{% extends "base" %}
         {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
         """,
@@ -142,11 +142,11 @@ def test_template_full():
         },
     )
 
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=cov,
         diff_coverage=diff_cov,
         previous_coverage_rate=decimal.Decimal("1.0"),
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
 The coverage rate went from `100%` to `100%` :arrow_right:
@@ -178,11 +178,11 @@ def test_template__no_new_lines_with_coverage(coverage_obj):
         files={},
     )
 
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=coverage_obj,
         diff_coverage=diff_cov,
         previous_coverage_rate=decimal.Decimal("1.0"),
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
 The coverage rate went from `100%` to `75%` :arrow_down:
@@ -196,11 +196,11 @@ _None of the new lines are part of the tested code. Therefore, there is no cover
 
 
 def test_template__no_branch_no_previous(coverage_obj_no_branch, diff_coverage_obj):
-    result = template.get_markdown_comment(
+    result = template.get_comment_markdown(
         coverage=coverage_obj_no_branch,
         diff_coverage=diff_coverage_obj,
         previous_coverage_rate=None,
-        base_template=template.read_template_file(),
+        base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
 > **Note**
@@ -223,29 +223,29 @@ Missing lines: `7`, `9`
 
 
 def test_read_template_file():
-    assert template.read_template_file().startswith(
+    assert template.read_template_file("comment.md.j2").startswith(
         "{% block title %}## Coverage report{% endblock title %}"
     )
 
 
 def test_template__no_marker(coverage_obj, diff_coverage_obj):
     with pytest.raises(template.MissingMarker):
-        template.get_markdown_comment(
+        template.get_comment_markdown(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
-            base_template=template.read_template_file(),
+            base_template=template.read_template_file("comment.md.j2"),
             custom_template="""foo bar""",
         )
 
 
 def test_template__broken_template(coverage_obj, diff_coverage_obj):
     with pytest.raises(template.TemplateError):
-        template.get_markdown_comment(
+        template.get_comment_markdown(
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
-            base_template=template.read_template_file(),
+            base_template=template.read_template_file("comment.md.j2"),
             custom_template="""{% extends "foo" %}""",
         )