Repository: py-cov-action/python-coverage-comment-action (python)

Issue:
Multi-module monorepo support?
Soo, I spent quite some time trying to make the action work with our private monorepo setup and looks like I failed :-(

The problem is that when in monorepo, you most probably are running the pytest/coverage inside some sub-module. And thus even with relative files, the paths are relative to that submodule.

So, when being merged, you kinda lost the "submodule" info and now when running the action in the repo root, it rightfully complains about a missing .py file. The file is present, but just inside some of the sub-modules.

If there are any ideas/examples of monorepo setups, I'd be glad to see any suggestions.


Also, while the whole sub-module thing (at least in our case) has a root-level Poetry project, when running the same `coverage combine` or `coverage json` locally under a particular poetry shell, it works fine! I believe the reason for that is cause all the sub-modules are listed in the root project as dependencies and poetry resolves these quite well.

Pull request:
Add SUBPROJECT_ID setting
Fixes #16 
Needs #184 :heavy_check_mark: 
Renders #180 obsolete (sorry)

I've finally taken the time to redo my PR for allowing monorepo settings.

I'm introducing a single setting (`SUBPROJECT_ID`) that does multiple things:
- changes the marker in the comment to allow multiple comment not to overwrite one another
- changes the name of the file we write as output to avoid clashes
- changes the name of the data branch.

Of course, it will make a lot more sense after we merge #184 and add `COVERAGE_PATH` (though I believe there may be cases where we may want the 2 settings to be independant: it's not unimaginable to have the coverage run on the root for both projects. It's also reasonable to use COVERAGE_PATH outside of a monorepo setting (e.g. if using `src/`) so while the doc for `SUBPROJECT_ID` mentions `COVERAGE_PATH`, I don't believe the 2 settings will be linked in the code.

Full backwards compatibility to be expected.

Patch:
diff --git a/README.md b/README.md
index 9766c4d..9d7ce41 100644
--- a/README.md
+++ b/README.md
@@ -166,7 +166,7 @@ jobs:
 ### Basic usage without external contributors
 
 If you don't expect external contributors, you don't need all the shenanigans
-with the artifacts and the 2nd workflow. This is likely to be the most straightfoward
+with the artifacts and the 2nd workflow. This is likely to be the most straightforward
 way to configure it for private repositories. It might look like this:
 
 ```yaml
@@ -198,7 +198,6 @@ jobs:
         run: make test # This is the part where you put your own test command
 
       - name: Coverage comment
-        id: coverage_comment
         uses: py-cov-action/python-coverage-comment-action@v3
         with:
           GITHUB_TOKEN: ${{ github.token }}
@@ -327,14 +326,25 @@ jobs:
     COMMENT_ARTIFACT_NAME: python-coverage-comment-action
 
     # Name of the file 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.
+    # In monorepo setting, see SUBPROJECT_ID.
     COMMENT_FILENAME: python-coverage-comment-action.txt
 
+    # This setting is only necessary if you plan to run the action multiple times
+    # in the same repository. It will be appended to the value of all the
+    # settings that need to be unique, so as for the action to avoid mixing
+    # up results of multiple runs.
+    # Affects `COMMENT_FILENAME`, `COVERAGE_DATA_BRANCH`.
+    # Ideally, use dashes (`-`) rather than underscrores (`_`) to split words,
+    # for consistency
+    SUBPROJECT_ID: null / "lib-name"
+
     # An alternative template for the comment for pull requests. See details below.
     COMMENT_TEMPLATE: The coverage rate is `{{ coverage.info.percent_covered | pct }}`{{ marker }}
 
     # Name of the branch in which coverage data will be stored on the repository.
-    # Please make sure that this branch is not protected.
+    # Default is 'python-coverage-comment-action-data'. Please make sure that this
+    # branch is not protected.
+    # In monorepo setting, see SUBPROJECT_ID.
     COVERAGE_DATA_BRANCH: python-coverage-comment-action-data
 
     # Deprecated, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging
@@ -383,6 +393,103 @@ coverage (percentage) of the whole project from the PR build:
 "Coverage: {{ coverage.info.percent_covered | pct }}{{ marker }}"
 ```
 
+## Monorepo setting
+
+In case you want to use the action multiple times with different parts of your
+source (so you have multiple codebases into a single repo), you'll
+need to use SUBPROJECT_ID with a different value for each launch. You may
+still use the same step for storing all files as artifacts. You'll end up with
+a different comment for each launch. Feel free to use the `COMMENT_TEMPLATE` if
+you want each comment to clearly state what it relates to.
+
+```yaml
+# .github/workflows/ci.yml
+name: CI
+
+on:
+  pull_request:
+  push:
+    branches:
+      - "main"
+
+jobs:
+  test:
+    name: Run tests & display coverage
+    runs-on: ubuntu-latest
+    permissions:
+      pull-requests: write
+      contents: write
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Test project 1
+        run: make -C project_1 test
+
+      - name: Test project 2
+        run: make -C project_2 test
+
+      - name: Coverage comment (project 1)
+        id: coverage_comment_1
+        uses: py-cov-action/python-coverage-comment-action@v3
+        with:
+          COVERAGE_PATH: project_1
+          SUBPROJECT_ID: project-1
+          GITHUB_TOKEN: ${{ github.token }}
+
+      - name: Coverage comment (project 2)
+        id: coverage_comment_2
+        uses: py-cov-action/python-coverage-comment-action@v3
+        with:
+          COVERAGE_PATH: project_2/src
+          SUBPROJECT_ID: project-2
+          GITHUB_TOKEN: ${{ github.token }}
+
+      - name: Store Pull Request comment to be posted
+        uses: actions/upload-artifact@v3
+        if: steps.coverage_comment_1.outputs.COMMENT_FILE_WRITTEN == 'true' || steps.coverage_comment_2.outputs.COMMENT_FILE_WRITTEN == 'true'
+        with:
+          name: python-coverage-comment-action
+          # Note the star
+          path: python-coverage-comment-action*.txt
+```
+
+```yaml
+# .github/workflows/coverage.yml
+name: Post coverage comment
+
+on:
+  workflow_run:
+    workflows: ["CI"]
+    types:
+      - completed
+
+jobs:
+  test:
+    name: Run tests & display coverage
+    runs-on: ubuntu-latest
+    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
+    permissions:
+      pull-requests: write
+      contents: write
+      actions: read
+    steps:
+      - name: Post comment
+        uses: py-cov-action/python-coverage-comment-action@v3
+        with:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
+          SUBPROJECT_ID: project-1
+          COVERAGE_PATH: project_1
+
+      - name: Post comment
+        uses: py-cov-action/python-coverage-comment-action@v3
+        with:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
+          SUBPROJECT_ID: project-2
+          COVERAGE_PATH: project_2/src
+```
+
 # Other topics
 
 ## Pinning
diff --git a/action.yml b/action.yml
index ef328b7..42249d1 100644
--- a/action.yml
+++ b/action.yml
@@ -27,6 +27,7 @@ inputs:
       Name of the branch in which coverage data will be stored on the repository.
       Default is 'python-coverage-comment-action-data'. Please make sure that this
       branch is not protected.
+      In monorepo setting, see SUBPROJECT_ID.
     default: python-coverage-comment-action-data
     required: false
   COVERAGE_PATH:
@@ -38,13 +39,25 @@ inputs:
   COMMENT_ARTIFACT_NAME:
     description: >
       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.
     default: python-coverage-comment-action
     required: false
   COMMENT_FILENAME:
     description: >
       Name of the file in which the body of the comment to post on the PR is stored.
+      In monorepo setting, see SUBPROJECT_ID.
     default: python-coverage-comment-action.txt
     required: false
+  SUBPROJECT_ID:
+    description: >
+      This setting is only necessary if you plan to run the action multiple
+      times in the same repository. It will be appended to the value of all the
+      settings that need to be unique, so as for the action to avoid mixing up
+      results of multiple runs. Ideally, use dashes (`-`) rather than
+      underscrores (`_`) to split words, for consistency.
+      Affects `COMMENT_FILENAME`, `COVERAGE_DATA_BRANCH`.
+    default: null
+    required: false
   MINIMUM_GREEN:
     description: >
       If the coverage percentage is above or equal to this value, the badge
@@ -67,7 +80,7 @@ inputs:
     default: false
   ANNOTATION_TYPE:
     description: >
-      Only needed if ANNOTATE_MISSING_LINES is set to true. This parameter allows you to choose between
+      Only relevant 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
@@ -78,8 +91,11 @@ inputs:
 outputs:
   COMMENT_FILE_WRITTEN:
     description: >
-      If "true", a comment file was written to COMMENT_FILENAME. If "false",
-      no comment file was written (because the comment was already posted).
+      This output is only set when running in PR mode. It's a boolean indicating
+      whether a comment file was written to COMMENT_FILENAME or not. If so,
+      you'll need to run the action in workflow_run mode to post it. If
+      "false", no comment file was written (likely because the comment was
+      already posted to the PR).
 runs:
   using: docker
   image: Dockerfile
@@ -91,6 +107,7 @@ runs:
     COVERAGE_PATH: ${{ inputs.COVERAGE_PATH }}
     COMMENT_ARTIFACT_NAME: ${{ inputs.COMMENT_ARTIFACT_NAME }}
     COMMENT_FILENAME: ${{ inputs.COMMENT_FILENAME }}
+    SUBPROJECT_ID: ${{ inputs.SUBPROJECT_ID }}
     MINIMUM_GREEN: ${{ inputs.MINIMUM_GREEN }}
     MINIMUM_ORANGE: ${{ inputs.MINIMUM_ORANGE }}
     MERGE_COVERAGE_FILES: ${{ inputs.MERGE_COVERAGE_FILES }}
diff --git a/coverage_comment/communication.py b/coverage_comment/communication.py
index 33a5e6f..bfe38d5 100644
--- a/coverage_comment/communication.py
+++ b/coverage_comment/communication.py
@@ -9,6 +9,7 @@ def get_readme_and_log(
     html_report_url: str,
     markdown_report: str,
     is_public: bool,
+    subproject_id: str | None = None,
 ) -> tuple[files.WriteFile, str]:
     readme_markdown = template.get_readme_markdown(
         is_public=is_public,
@@ -18,6 +19,7 @@ def get_readme_and_log(
         direct_image_url=image_urls["direct"],
         endpoint_image_url=image_urls["endpoint"],
         dynamic_image_url=image_urls["dynamic"],
+        subproject_id=subproject_id,
     )
     log_message = template.get_log_message(
         is_public=is_public,
@@ -26,6 +28,7 @@ def get_readme_and_log(
         direct_image_url=image_urls["direct"],
         endpoint_image_url=image_urls["endpoint"],
         dynamic_image_url=image_urls["dynamic"],
+        subproject_id=subproject_id,
     )
     readme = files.WriteFile(
         path=pathlib.Path("README.md"),
diff --git a/coverage_comment/github.py b/coverage_comment/github.py
index 5653a18..fb7b51bf 100644
--- a/coverage_comment/github.py
+++ b/coverage_comment/github.py
@@ -113,7 +113,7 @@ def get_pr_number_from_workflow_run(
         raise CannotDeterminePR(f"No open PR found for branch {full_branch}")
 
 
-def get_my_login(github: github_client.GitHub):
+def get_my_login(github: github_client.GitHub) -> str:
     try:
         response = github.user.get()
     except github_client.Forbidden:
diff --git a/coverage_comment/main.py b/coverage_comment/main.py
index f96d7b3..93b68d3 100644
--- a/coverage_comment/main.py
+++ b/coverage_comment/main.py
@@ -120,12 +120,13 @@ def generate_comment(
     previous_coverage_data_file = storage.get_datafile_contents(
         github=gh,
         repository=config.GITHUB_REPOSITORY,
-        branch=config.COVERAGE_DATA_BRANCH,
+        branch=config.FINAL_COVERAGE_DATA_BRANCH,
     )
     previous_coverage = None
     if previous_coverage_data_file:
         previous_coverage = files.parse_datafile(contents=previous_coverage_data_file)
 
+    marker = template.get_marker(marker_id=config.SUBPROJECT_ID)
     try:
         comment = template.get_comment_markdown(
             coverage=coverage,
@@ -133,6 +134,8 @@ def generate_comment(
             previous_coverage_rate=previous_coverage,
             base_template=template.read_template_file("comment.md.j2"),
             custom_template=config.COMMENT_TEMPLATE,
+            marker=marker,
+            subproject_id=config.SUBPROJECT_ID,
         )
     except template.MissingMarker:
         log.error(
@@ -167,7 +170,7 @@ def generate_comment(
             repository=config.GITHUB_REPOSITORY,
             pr_number=config.GITHUB_PR_NUMBER,
             contents=comment,
-            marker=template.MARKER,
+            marker=marker,
         )
     except github.CannotPostComment:
         log.debug("Exception when posting comment", exc_info=True)
@@ -178,7 +181,7 @@ def generate_comment(
             "on PR comments for external PRs)."
         )
         comment_file.store_file(
-            filename=config.COMMENT_FILENAME,
+            filename=config.FINAL_COMMENT_FILENAME,
             content=comment,
         )
         github.set_output(github_output=config.GITHUB_OUTPUT, COMMENT_FILE_WRITTEN=True)
@@ -224,7 +227,7 @@ def post_comment(config: settings.Config, github_session: httpx.Client) -> int:
             repository=config.GITHUB_REPOSITORY,
             artifact_name=config.COMMENT_ARTIFACT_NAME,
             run_id=config.GITHUB_PR_RUN_ID,
-            filename=config.COMMENT_FILENAME,
+            filename=config.FINAL_COMMENT_FILENAME,
         )
     except github.NoArtifact:
         log.info(
@@ -240,7 +243,7 @@ def post_comment(config: settings.Config, github_session: httpx.Client) -> int:
         repository=config.GITHUB_REPOSITORY,
         pr_number=pr_number,
         contents=comment,
-        marker=template.MARKER,
+        marker=template.get_marker(marker_id=config.SUBPROJECT_ID),
     )
     log.info("Comment posted in PR")
 
@@ -294,14 +297,14 @@ def save_coverage_data_files(
         storage.get_raw_file_url,
         is_public=is_public,
         repository=config.GITHUB_REPOSITORY,
-        branch=config.COVERAGE_DATA_BRANCH,
+        branch=config.FINAL_COVERAGE_DATA_BRANCH,
     )
     readme_url = storage.get_repo_file_url(
-        branch=config.COVERAGE_DATA_BRANCH,
+        branch=config.FINAL_COVERAGE_DATA_BRANCH,
         repository=config.GITHUB_REPOSITORY,
     )
     html_report_url = storage.get_html_report_url(
-        branch=config.COVERAGE_DATA_BRANCH,
+        branch=config.FINAL_COVERAGE_DATA_BRANCH,
         repository=config.GITHUB_REPOSITORY,
     )
     readme_file, log_message = communication.get_readme_and_log(
@@ -310,12 +313,13 @@ def save_coverage_data_files(
         image_urls=files.get_urls(url_getter=url_getter),
         html_report_url=html_report_url,
         markdown_report=markdown_report,
+        subproject_id=config.SUBPROJECT_ID,
     )
     operations.append(readme_file)
     storage.commit_operations(
         operations=operations,
         git=git,
-        branch=config.COVERAGE_DATA_BRANCH,
+        branch=config.FINAL_COVERAGE_DATA_BRANCH,
     )
 
     log.info(log_message)
diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py
index 94aadce..13a56a2 100644
--- a/coverage_comment/settings.py
+++ b/coverage_comment/settings.py
@@ -45,6 +45,7 @@ class Config:
     COVERAGE_PATH: pathlib.Path = pathlib.Path(".")
     COMMENT_ARTIFACT_NAME: str = "python-coverage-comment-action"
     COMMENT_FILENAME: pathlib.Path = pathlib.Path("python-coverage-comment-action.txt")
+    SUBPROJECT_ID: str | None = None
     GITHUB_OUTPUT: pathlib.Path | None = None
     MINIMUM_GREEN: decimal.Decimal = decimal.Decimal("100")
     MINIMUM_ORANGE: decimal.Decimal = decimal.Decimal("70")
@@ -119,6 +120,20 @@ class Config:
             return int(self.GITHUB_REF.split("/")[2])
         return None
 
+    @property
+    def FINAL_COMMENT_FILENAME(self):
+        filename = self.COMMENT_FILENAME
+        if self.SUBPROJECT_ID:
+            new_name = f"{filename.stem}-{self.SUBPROJECT_ID}{filename.suffix}"
+            return filename.parent / new_name
+        return filename
+
+    @property
+    def FINAL_COVERAGE_DATA_BRANCH(self):
+        return self.COVERAGE_DATA_BRANCH + (
+            f"-{self.SUBPROJECT_ID}" if self.SUBPROJECT_ID else ""
+        )
+
     # We need to type environ as a MutableMapping because that's what
     # os.environ is, and just saying `dict[str, str]` is not enough to make
     # mypy happy
diff --git a/coverage_comment/template.py b/coverage_comment/template.py
index 8cf8338..02f2df8 100644
--- a/coverage_comment/template.py
+++ b/coverage_comment/template.py
@@ -7,7 +7,9 @@ from jinja2.sandbox import SandboxedEnvironment
 
 from coverage_comment import coverage as coverage_module
 
-MARKER = """<!-- This comment was produced by python-coverage-comment-action -->"""
+MARKER = (
+    """<!-- This comment was produced by python-coverage-comment-action{id_part} -->"""
+)
 
 
 def uptodate():
@@ -39,11 +41,17 @@ class TemplateError(Exception):
     pass
 
 
+def get_marker(marker_id: str | None):
+    return MARKER.format(id_part=f" (id: {marker_id})" if marker_id else "")
+
+
 def get_comment_markdown(
     coverage: coverage_module.Coverage,
     diff_coverage: coverage_module.DiffCoverage,
     previous_coverage_rate: decimal.Decimal | None,
     base_template: str,
+    marker: str,
+    subproject_id: str | None = None,
     custom_template: str | None = None,
 ):
     loader = CommentLoader(base_template=base_template, custom_template=custom_template)
@@ -55,12 +63,13 @@ def get_comment_markdown(
             previous_coverage_rate=previous_coverage_rate,
             coverage=coverage,
             diff_coverage=diff_coverage,
-            marker=MARKER,
+            subproject_id=subproject_id,
+            marker=marker,
         )
     except jinja2.exceptions.TemplateError as exc:
         raise TemplateError from exc
 
-    if MARKER not in comment:
+    if marker not in comment:
         raise MissingMarker()
 
     return comment
@@ -74,6 +83,7 @@ def get_readme_markdown(
     html_report_url: str | None,
     dynamic_image_url: str | None,
     endpoint_image_url: str | None,
+    subproject_id: str | None = None,
 ):
     env = SandboxedEnvironment()
     template = jinja2.Template(read_template_file("readme.md.j2"))
@@ -85,6 +95,7 @@ def get_readme_markdown(
         html_report_url=html_report_url,
         dynamic_image_url=dynamic_image_url,
         endpoint_image_url=endpoint_image_url,
+        subproject_id=subproject_id,
     )
 
 
@@ -95,6 +106,7 @@ def get_log_message(
     html_report_url: str | None,
     dynamic_image_url: str | None,
     endpoint_image_url: str | None,
+    subproject_id: str | None = None,
 ):
     env = SandboxedEnvironment()
     template = jinja2.Template(read_template_file("log.txt.j2"))
@@ -105,6 +117,7 @@ def get_log_message(
         endpoint_image_url=endpoint_image_url,
         dynamic_image_url=dynamic_image_url,
         readme_url=readme_url,
+        subproject_id=subproject_id,
     )
 
 
diff --git a/coverage_comment/template_files/comment.md.j2 b/coverage_comment/template_files/comment.md.j2
index 9554314..5d7639a 100644
--- a/coverage_comment/template_files/comment.md.j2
+++ b/coverage_comment/template_files/comment.md.j2
@@ -1,4 +1,4 @@
-{% block title %}## Coverage report{% endblock title %}
+{% block title %}## Coverage report{% if subproject_id %} ({{ subproject_id  }}){% endif %}{% endblock title %}
 {% block coverage_evolution -%}
 {% if previous_coverage_rate -%}
 {% block coverage_evolution_wording -%}
diff --git a/coverage_comment/template_files/log.txt.j2 b/coverage_comment/template_files/log.txt.j2
index 0b69f09..b8d252c 100644
--- a/coverage_comment/template_files/log.txt.j2
+++ b/coverage_comment/template_files/log.txt.j2
@@ -1,3 +1,6 @@
+{% if subproject_id %} Coverage info for {{ subproject_id }}:
+
+{% endif -%}
 {% if is_public -%}
 You can browse the full coverage report at:
     {{ html_report_url }}
diff --git a/coverage_comment/template_files/readme.md.j2 b/coverage_comment/template_files/readme.md.j2
index 8f85075..e7d7d69 100644
--- a/coverage_comment/template_files/readme.md.j2
+++ b/coverage_comment/template_files/readme.md.j2
@@ -1,4 +1,4 @@
-# Repository Coverage
+# Repository Coverage{% if subproject_id %} ({{ subproject_id }}){% endif %}
 
 {% if is_public -%}
 [Full report]({{ html_report_url }})


Test patch:
diff --git a/tests/end_to_end/conftest.py b/tests/end_to_end/conftest.py
index 07e2f79..3f9e83b 100644
--- a/tests/end_to_end/conftest.py
+++ b/tests/end_to_end/conftest.py
@@ -191,7 +191,7 @@ def gh_other_username(gh_other):
 
 
 @pytest.fixture
-def git_repo(cd, git, action_ref, code_path):
+def git_repo(cd, git, action_ref, code_path, subproject_id):
     with cd("repo") as repo:
         git("init", "-b", "main")
         # Copy .github
@@ -213,6 +213,7 @@ def git_repo(cd, git, action_ref, code_path):
                 file.read_text()
                 .replace("__ACTION_REF__", action_ref)
                 .replace("__ACTION_COVERAGE_PATH__", str(code_path))
+                .replace("__ACTION_SUBPROJECT_ID__", json_module.dumps(subproject_id))
             )
             file.write_text(content)
 
@@ -239,6 +240,12 @@ def code_path(request):
     return pathlib.Path(*mark.args) if mark else pathlib.Path(".")
 
 
+@pytest.fixture
+def subproject_id(request):
+    mark = request.node.get_closest_marker("subproject_id")
+    return mark.args[0] if mark else None
+
+
 @pytest.fixture
 def repo_full_name(repo_name, gh_me_username):
     return f"{gh_me_username}/{repo_name}"
diff --git a/tests/end_to_end/repo/.github/workflows/ci.yml b/tests/end_to_end/repo/.github/workflows/ci.yml
index 5d64e22..ca95b81 100644
--- a/tests/end_to_end/repo/.github/workflows/ci.yml
+++ b/tests/end_to_end/repo/.github/workflows/ci.yml
@@ -41,10 +41,11 @@ jobs:
           ANNOTATE_MISSING_LINES: true
           ANNOTATION_TYPE: notice
           COVERAGE_PATH: __ACTION_COVERAGE_PATH__
+          SUBPROJECT_ID: __ACTION_SUBPROJECT_ID__
 
       - name: Store Pull Request comment to be posted
         uses: actions/upload-artifact@v3
         if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true'
         with:
           name: python-coverage-comment-action
-          path: python-coverage-comment-action.txt
+          path: python-coverage-comment-action*.txt
diff --git a/tests/end_to_end/repo/.github/workflows/coverage-comment.yml b/tests/end_to_end/repo/.github/workflows/coverage-comment.yml
index 9db6642..26668d7 100644
--- a/tests/end_to_end/repo/.github/workflows/coverage-comment.yml
+++ b/tests/end_to_end/repo/.github/workflows/coverage-comment.yml
@@ -22,3 +22,4 @@ jobs:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
           COVERAGE_PATH: __ACTION_COVERAGE_PATH__
+          SUBPROJECT_ID: __ACTION_SUBPROJECT_ID__
diff --git a/tests/end_to_end/test_all.py b/tests/end_to_end/test_all.py
index 4b44529..69edae6 100644
--- a/tests/end_to_end/test_all.py
+++ b/tests/end_to_end/test_all.py
@@ -6,6 +6,7 @@ import pytest
 
 @pytest.mark.repo_suffix("public")
 @pytest.mark.code_path("subdir")
+@pytest.mark.subproject_id("my-great-project")
 def test_public_repo(
     gh_create_repo,
     wait_for_run_to_start,
@@ -71,15 +72,11 @@ def test_public_repo(
     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"
-    )
+    data_branch_url = f"https://github.com/{repo_full_name}/tree/python-coverage-comment-action-data-my-great-project"
     assert data_branch_url in links
 
     # Time to check the Readme contents
-    raw_url_prefix = (
-        f"https://github.com/{repo_full_name}/raw/python-coverage-comment-action-data"
-    )
+    raw_url_prefix = f"https://github.com/{repo_full_name}/raw/python-coverage-comment-action-data-my-great-project"
 
     readme_url = f"{raw_url_prefix}/README.md"
     response = client.get(readme_url, follow_redirects=True)
@@ -124,6 +121,11 @@ def test_public_repo(
         fail_value="\n",
     )
     assert ":arrow_up:" in comment
+    assert "## Coverage report (my-great-project)" in comment
+    assert (
+        "This comment was produced by python-coverage-comment-action (id: my-great-project)"
+        in comment
+    )
 
     # Let's merge the PR and see if everything works fine
     gh_me("pr", "merge", "1", "--merge")
diff --git a/tests/unit/test_communication.py b/tests/unit/test_communication.py
index 7fdb2b0..033cdf7 100644
--- a/tests/unit/test_communication.py
+++ b/tests/unit/test_communication.py
@@ -35,6 +35,23 @@ def test_get_readme_and_log__public():
     assert "https://c" in log
 
 
+def test_get_readme_and_log__subproject():
+    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!**",
+        subproject_id="my-subproject",
+    )
+    assert "Coverage info for my-subproject" in log
+    assert "# Repository Coverage (my-subproject)" in readme_file.contents
+
+
 def test_get_readme_and_log__private():
     readme_file, log = communication.get_readme_and_log(
         is_public=False,
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
index edab171..428be88 100644
--- a/tests/unit/test_settings.py
+++ b/tests/unit/test_settings.py
@@ -35,6 +35,7 @@ def test_config__from_environ__ok():
             "GITHUB_STEP_SUMMARY": "step_summary",
             "COMMENT_ARTIFACT_NAME": "baz",
             "COMMENT_FILENAME": "qux",
+            "SUBPROJECT_ID": "subproject",
             "COMMENT_TEMPLATE": "footemplate",
             "COVERAGE_DATA_BRANCH": "branchname",
             "COVERAGE_PATH": "source_folder/",
@@ -57,6 +58,7 @@ def test_config__from_environ__ok():
         GITHUB_STEP_SUMMARY=pathlib.Path("step_summary"),
         COMMENT_ARTIFACT_NAME="baz",
         COMMENT_FILENAME=pathlib.Path("qux"),
+        SUBPROJECT_ID="subproject",
         COMMENT_TEMPLATE="footemplate",
         COVERAGE_DATA_BRANCH="branchname",
         COVERAGE_PATH=pathlib.Path("source_folder/"),
@@ -156,3 +158,19 @@ def test_config__invalid_annotation_type():
 )
 def test_str_to_bool(input, output):
     assert settings.str_to_bool(input) is output
+
+
+def test_final_comment_filename(config):
+    config_obj = config(
+        COMMENT_FILENAME=pathlib.Path("foo.txt"),
+        SUBPROJECT_ID="bar",
+    )
+    assert config_obj.FINAL_COMMENT_FILENAME == pathlib.Path("foo-bar.txt")
+
+
+def test_final_coverage_data_branch(config):
+    config_obj = config(
+        COVERAGE_DATA_BRANCH="foo",
+        SUBPROJECT_ID="bar",
+    )
+    assert config_obj.FINAL_COVERAGE_DATA_BRANCH == "foo-bar"
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
index 78a298b..5ffe4f8 100644
--- a/tests/unit/test_template.py
+++ b/tests/unit/test_template.py
@@ -13,6 +13,7 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj):
             coverage=coverage_obj,
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
+            marker="<!-- foo -->",
             base_template="""
         {{ previous_coverage_rate | pct }}
         {{ coverage.info.percent_covered | pct }}
@@ -33,7 +34,7 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj):
         "75%",
         "80%",
         "bar",
-        "<!-- This comment was produced by python-coverage-comment-action -->",
+        "<!-- foo -->",
     ]
 
     assert result == expected
@@ -45,11 +46,13 @@ def test_template(coverage_obj, diff_coverage_obj):
         diff_coverage=diff_coverage_obj,
         previous_coverage_rate=decimal.Decimal("0.92"),
         base_template=template.read_template_file("comment.md.j2"),
+        marker="<!-- foo -->",
+        subproject_id="foo",
         custom_template="""{% extends "base" %}
         {% block emoji_coverage_down %}:sob:{% endblock emoji_coverage_down %}
         """,
     )
-    expected = """## Coverage report
+    expected = """## Coverage report (foo)
 The coverage rate went from `92%` to `75%` :sob:
 The branch rate is `50%`.
 
@@ -63,7 +66,7 @@ The branch rate is `50%`.
 Missing lines: `7`, `9`
 
 </details>
-<!-- This comment was produced by python-coverage-comment-action -->"""
+<!-- foo -->"""
     assert result == expected
 
 
@@ -147,6 +150,7 @@ def test_template_full():
         coverage=cov,
         diff_coverage=diff_cov,
         previous_coverage_rate=decimal.Decimal("1.0"),
+        marker="<!-- foo -->",
         base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
@@ -166,7 +170,7 @@ Missing lines: `5`
 `100%` of new lines are covered (`100%` of the complete file).
 
 </details>
-<!-- This comment was produced by python-coverage-comment-action -->"""
+<!-- foo -->"""
     assert result == expected
 
 
@@ -183,6 +187,7 @@ def test_template__no_new_lines_with_coverage(coverage_obj):
         coverage=coverage_obj,
         diff_coverage=diff_cov,
         previous_coverage_rate=decimal.Decimal("1.0"),
+        marker="<!-- foo -->",
         base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
@@ -192,7 +197,7 @@ The branch rate is `50%`.
 _None of the new lines are part of the tested code. Therefore, there is no coverage data about them._
 
 
-<!-- This comment was produced by python-coverage-comment-action -->"""
+<!-- foo -->"""
     assert result == expected
 
 
@@ -201,6 +206,7 @@ def test_template__no_branch_no_previous(coverage_obj_no_branch, diff_coverage_o
         coverage=coverage_obj_no_branch,
         diff_coverage=diff_coverage_obj,
         previous_coverage_rate=None,
+        marker="<!-- foo -->",
         base_template=template.read_template_file("comment.md.j2"),
     )
     expected = """## Coverage report
@@ -219,13 +225,13 @@ The coverage rate is `75%`.
 Missing lines: `7`, `9`
 
 </details>
-<!-- This comment was produced by python-coverage-comment-action -->"""
+<!-- foo -->"""
     assert result == expected
 
 
 def test_read_template_file():
     assert template.read_template_file("comment.md.j2").startswith(
-        "{% block title %}## Coverage report{% endblock title %}"
+        "{% block title %}## Coverage report{% if subproject_id %}"
     )
 
 
@@ -236,6 +242,7 @@ def test_template__no_marker(coverage_obj, diff_coverage_obj):
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
             base_template=template.read_template_file("comment.md.j2"),
+            marker="<!-- foo -->",
             custom_template="""foo bar""",
         )
 
@@ -247,6 +254,7 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj):
             diff_coverage=diff_coverage_obj,
             previous_coverage_rate=decimal.Decimal("0.92"),
             base_template=template.read_template_file("comment.md.j2"),
+            marker="<!-- foo -->",
             custom_template="""{% extends "foo" %}""",
         )
 
@@ -268,3 +276,17 @@ def test_pct(value, displayed_coverage):
 
 def test_uptodate():
     assert template.uptodate() is True
+
+
+@pytest.mark.parametrize(
+    "marker_id, result",
+    [
+        (None, "<!-- This comment was produced by python-coverage-comment-action -->"),
+        (
+            "foo",
+            "<!-- This comment was produced by python-coverage-comment-action (id: foo) -->",
+        ),
+    ],
+)
+def test_get_marker(marker_id, result):
+    assert template.get_marker(marker_id=marker_id) == result