From 08c24ee622b712032bef6565475f34dbf4fdf408 Mon Sep 17 00:00:00 2001 From: Dave Golombek Date: Sun, 26 Oct 2025 21:30:03 -0400 Subject: [PATCH 1/5] Add support for custom event dispatching ### Description This addresses #615 by adding the new ACTIVITY parameters. When this is specified, it overrides the default event-to-activity heuristic in #find_activity and selects the activity to run directly. This allows the end user to write their logic in the workflows when required. ### Testing I've added unit tests for these, but I haven't added integration or end-to-end tests, I was unable to get the environment set up correctly. --- README.md | 34 ++++++++++++++++++++++++++++++++++ coverage_comment/activity.py | 24 ++++++++++++++++++++---- coverage_comment/main.py | 31 ++++++++++++++++++------------- coverage_comment/settings.py | 1 + tests/integration/test_main.py | 2 +- tests/unit/test_activity.py | 25 +++++++++++++++++-------- 6 files changed, 91 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1e4e7de2..a861086e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,36 @@ These files include: See [an example](https://github.com/py-cov-action/python-coverage-comment-action-v3-example) +### Determining the mode + +By default, the action will attempt to pick the appropriate mode based on the +current branch, whether or not it's in a pull request, and if that pull request +is open or closed. This frequently results in the correct action taking place, +but is only a heuristic. If you need more precise control, you should specify +the `ACTIVITY` parameter to directly choose the mode. It may be one of: + +- `process_pr`, to select [PR mode](#pr-mode) +- `save_coverage_data_files`, to select [Default branch mode](#default-branch-mode) +- `post_comment`, to select [Commenting on the PR on the `push` event](#commenting-on-the-pr-on-the-push-event) + +Combining this with [Github's Expressions] +(https://docs.github.com/en/actions/reference/workflows-and-actions/expressions) you can +build out the the custom handling needed. For example: + +```yaml + - name: Coverage comment + id: coverage_comment + uses: py-cov-action/python-coverage-comment-action@v3 + with: + GITHUB_TOKEN: ${{ github.token }} + activity: "${{ github.event_name == 'push' && 'save_coverage_data_files' || 'process_pr' }}" + + # or + + with: + activity: "${{ (github.event_name == 'push' && github.ref_name == 'main') && 'save_coverage_data_files' || 'process_pr' }}" +``` + ## Usage ### Setup @@ -498,6 +528,10 @@ Usage may look like this # Deprecated, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging VERBOSE: false + + # The specific activity that should be taken on this event, see + # [Determining the mode](#determining-the-mode) above. + ACTIVITY: "" ``` ### Commenting on the PR on the `push` event diff --git a/coverage_comment/activity.py b/coverage_comment/activity.py index 9444363f..91f647a2 100644 --- a/coverage_comment/activity.py +++ b/coverage_comment/activity.py @@ -8,20 +8,36 @@ from __future__ import annotations +from enum import Enum + + +class Activity(Enum): + PROCESS_PR = "process_pr" + POST_COMMENT = "post_comment" + SAVE_COVERAGE_DATA_FILES = "save_coverage_data_files" class ActivityNotFound(Exception): pass +class ActivityConfigError(Exception): + pass + +def validate_activity( + activity: str +) -> Activity: + if activity not in [a.value for a in Activity]: + raise ActivityConfigError(f"Invalid activity: {activity}") + return Activity(activity) def find_activity( event_name: str, is_default_branch: bool, event_type: str | None, is_pr_merged: bool, -) -> str: +) -> Activity: """Find the activity to perform based on the event type and payload.""" if event_name == "workflow_run": - return "post_comment" + return Activity.POST_COMMENT if ( (event_name == "push" and is_default_branch) @@ -31,9 +47,9 @@ def find_activity( ): if event_name == "pull_request" and event_type == "closed" and not is_pr_merged: raise ActivityNotFound - return "save_coverage_data_files" + return Activity.SAVE_COVERAGE_DATA_FILES if event_name not in {"pull_request", "push", "merge_group"}: raise ActivityNotFound - return "process_pr" + return Activity.PROCESS_PR diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 1f377eb0..92eee95d 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -39,22 +39,27 @@ def action( github=gh, repository=config.GITHUB_REPOSITORY ) try: - activity = activity_module.find_activity( - event_name=event_name, - is_default_branch=repo_info.is_default_branch(ref=config.GITHUB_REF), - event_type=config.GITHUB_EVENT_TYPE, - is_pr_merged=config.IS_PR_MERGED, - ) + if config.ACTIVITY: + activity = activity_module.validate_activity(config.ACTIVITY) + else: + activity = activity_module.find_activity( + event_name=event_name, + is_default_branch=repo_info.is_default_branch(ref=config.GITHUB_REF), + event_type=config.GITHUB_EVENT_TYPE, + is_pr_merged=config.IS_PR_MERGED, + ) except activity_module.ActivityNotFound: log.error( - 'This action has only been designed to work for "pull_request", "push", ' - f'"workflow_run", "schedule" or "merge_group" actions, not "{event_name}". Because there ' - "are security implications. If you have a different usecase, please open an issue, " - "we'll be glad to add compatibility." + 'This action\'s default behavior is to determine the appropriate ' + 'mode based on the current branch, whether or not it\'s in a pull ' + 'request, and if that pull request is open or closed. This ' + 'frequently results in the correct action taking place, but is ' + 'only a heuristic. If you need more precise control, you should ' + 'specify the "ACTIVITY" parameter as described in the documentation.' ) return 1 - if activity == "save_coverage_data_files": + if activity == activity_module.Activity.SAVE_COVERAGE_DATA_FILES: return save_coverage_data_files( config=config, git=git, @@ -62,7 +67,7 @@ def action( repo_info=repo_info, ) - elif activity == "process_pr": + elif activity == activity_module.Activity.PROCESS_PR: return process_pr( config=config, gh=gh, @@ -70,7 +75,7 @@ def action( ) else: - # activity == "post_comment": + # activity == activity_module.Activity.POST_COMMENT: return post_comment( config=config, gh=gh, diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py index 8d122679..21ad6984 100644 --- a/coverage_comment/settings.py +++ b/coverage_comment/settings.py @@ -67,6 +67,7 @@ class Config: ANNOTATION_TYPE: str = "warning" MAX_FILES_IN_COMMENT: int = 25 USE_GH_PAGES_HTML_URL: bool = False + ACTIVITY: str | None = None VERBOSE: bool = False # Only for debugging, not exposed in the action: FORCE_WORKFLOW_RUN: bool = False diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 57a2620c..271fe460 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -61,7 +61,7 @@ def test_action__invalid_event_name(session, push_config, in_integration_env, ge ) assert result == 1 - assert get_logs("ERROR", "This action has only been designed to work for") + assert get_logs("ERROR", "This action's default behavior is to determine") def get_expected_output( diff --git a/tests/unit/test_activity.py b/tests/unit/test_activity.py index b67627b4..2946e7e3 100644 --- a/tests/unit/test_activity.py +++ b/tests/unit/test_activity.py @@ -3,19 +3,20 @@ import pytest from coverage_comment import activity +from coverage_comment.settings import Config @pytest.mark.parametrize( "event_name, is_default_branch, event_type, is_pr_merged, expected_activity", [ - ("workflow_run", True, None, False, "post_comment"), - ("push", True, None, False, "save_coverage_data_files"), - ("push", False, None, False, "process_pr"), - ("pull_request", True, "closed", True, "save_coverage_data_files"), - ("pull_request", True, None, False, "process_pr"), - ("pull_request", False, None, False, "process_pr"), - ("schedule", False, None, False, "save_coverage_data_files"), - ("merge_group", False, None, False, "save_coverage_data_files"), + ("workflow_run", True, None, False, activity.Activity.POST_COMMENT), + ("push", True, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), + ("push", False, None, False, activity.Activity.PROCESS_PR), + ("pull_request", True, "closed", True, activity.Activity.SAVE_COVERAGE_DATA_FILES), + ("pull_request", True, None, False, activity.Activity.PROCESS_PR), + ("pull_request", False, None, False, activity.Activity.PROCESS_PR), + ("schedule", False, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), + ("merge_group", False, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), ], ) def test_find_activity( @@ -48,3 +49,11 @@ def test_find_activity_pr_closed_not_merged(): event_type="closed", is_pr_merged=False, ) + +def test_validate_activity__invalid(): + with pytest.raises(activity.ActivityConfigError): + activity.validate_activity("invalid") + +def test_validate_activity__valid(): + result = activity.validate_activity("process_pr") + assert result == activity.Activity.PROCESS_PR From 007b7cfdf91cd53d060acd5aaa3c6157f01a98e9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:21:14 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- coverage_comment/activity.py | 8 +++++--- coverage_comment/main.py | 10 +++++----- tests/unit/test_activity.py | 11 +++++++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/coverage_comment/activity.py b/coverage_comment/activity.py index 91f647a2..0a4f5a71 100644 --- a/coverage_comment/activity.py +++ b/coverage_comment/activity.py @@ -16,19 +16,21 @@ class Activity(Enum): POST_COMMENT = "post_comment" SAVE_COVERAGE_DATA_FILES = "save_coverage_data_files" + class ActivityNotFound(Exception): pass + class ActivityConfigError(Exception): pass -def validate_activity( - activity: str -) -> Activity: + +def validate_activity(activity: str) -> Activity: if activity not in [a.value for a in Activity]: raise ActivityConfigError(f"Invalid activity: {activity}") return Activity(activity) + def find_activity( event_name: str, is_default_branch: bool, diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 92eee95d..fc462857 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -50,11 +50,11 @@ def action( ) except activity_module.ActivityNotFound: log.error( - 'This action\'s default behavior is to determine the appropriate ' - 'mode based on the current branch, whether or not it\'s in a pull ' - 'request, and if that pull request is open or closed. This ' - 'frequently results in the correct action taking place, but is ' - 'only a heuristic. If you need more precise control, you should ' + "This action's default behavior is to determine the appropriate " + "mode based on the current branch, whether or not it's in a pull " + "request, and if that pull request is open or closed. This " + "frequently results in the correct action taking place, but is " + "only a heuristic. If you need more precise control, you should " 'specify the "ACTIVITY" parameter as described in the documentation.' ) return 1 diff --git a/tests/unit/test_activity.py b/tests/unit/test_activity.py index 2946e7e3..0713d119 100644 --- a/tests/unit/test_activity.py +++ b/tests/unit/test_activity.py @@ -3,7 +3,6 @@ import pytest from coverage_comment import activity -from coverage_comment.settings import Config @pytest.mark.parametrize( @@ -12,7 +11,13 @@ ("workflow_run", True, None, False, activity.Activity.POST_COMMENT), ("push", True, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), ("push", False, None, False, activity.Activity.PROCESS_PR), - ("pull_request", True, "closed", True, activity.Activity.SAVE_COVERAGE_DATA_FILES), + ( + "pull_request", + True, + "closed", + True, + activity.Activity.SAVE_COVERAGE_DATA_FILES, + ), ("pull_request", True, None, False, activity.Activity.PROCESS_PR), ("pull_request", False, None, False, activity.Activity.PROCESS_PR), ("schedule", False, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), @@ -50,10 +55,12 @@ def test_find_activity_pr_closed_not_merged(): is_pr_merged=False, ) + def test_validate_activity__invalid(): with pytest.raises(activity.ActivityConfigError): activity.validate_activity("invalid") + def test_validate_activity__valid(): result = activity.validate_activity("process_pr") assert result == activity.Activity.PROCESS_PR From 489ae91974893f7aafdc46ef4544aefc53cf1ebd Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Mon, 22 Jun 2026 00:12:57 +0200 Subject: [PATCH 3/5] rename activity module > activities --- .../{activity.py => activities.py} | 0 coverage_comment/main.py | 2 +- tests/unit/test_activity.py | 44 +++++++++---------- tests/unit/test_settings.py | 6 ++- 4 files changed, 25 insertions(+), 27 deletions(-) rename coverage_comment/{activity.py => activities.py} (100%) diff --git a/coverage_comment/activity.py b/coverage_comment/activities.py similarity index 100% rename from coverage_comment/activity.py rename to coverage_comment/activities.py diff --git a/coverage_comment/main.py b/coverage_comment/main.py index fc462857..34d62c28 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -7,7 +7,7 @@ import httpx -from coverage_comment import activity as activity_module +from coverage_comment import activities as activity_module from coverage_comment import ( comment_file, communication, diff --git a/tests/unit/test_activity.py b/tests/unit/test_activity.py index 0713d119..b598369c 100644 --- a/tests/unit/test_activity.py +++ b/tests/unit/test_activity.py @@ -2,32 +2,38 @@ import pytest -from coverage_comment import activity +from coverage_comment import activities @pytest.mark.parametrize( "event_name, is_default_branch, event_type, is_pr_merged, expected_activity", [ - ("workflow_run", True, None, False, activity.Activity.POST_COMMENT), - ("push", True, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), - ("push", False, None, False, activity.Activity.PROCESS_PR), + ("workflow_run", True, None, False, activities.Activity.POST_COMMENT), + ("push", True, None, False, activities.Activity.SAVE_COVERAGE_DATA_FILES), + ("push", False, None, False, activities.Activity.PROCESS_PR), ( "pull_request", True, "closed", True, - activity.Activity.SAVE_COVERAGE_DATA_FILES, + activities.Activity.SAVE_COVERAGE_DATA_FILES, + ), + ("pull_request", True, None, False, activities.Activity.PROCESS_PR), + ("pull_request", False, None, False, activities.Activity.PROCESS_PR), + ("schedule", False, None, False, activities.Activity.SAVE_COVERAGE_DATA_FILES), + ( + "merge_group", + False, + None, + False, + activities.Activity.SAVE_COVERAGE_DATA_FILES, ), - ("pull_request", True, None, False, activity.Activity.PROCESS_PR), - ("pull_request", False, None, False, activity.Activity.PROCESS_PR), - ("schedule", False, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), - ("merge_group", False, None, False, activity.Activity.SAVE_COVERAGE_DATA_FILES), ], ) def test_find_activity( event_name, is_default_branch, event_type, is_pr_merged, expected_activity ): - result = activity.find_activity( + result = activities.find_activity( event_name=event_name, is_default_branch=is_default_branch, event_type=event_type, @@ -37,8 +43,8 @@ def test_find_activity( def test_find_activity_not_found(): - with pytest.raises(activity.ActivityNotFound): - activity.find_activity( + with pytest.raises(activities.ActivityNotFound): + activities.find_activity( event_name="not_found", is_default_branch=False, event_type="not_found", @@ -47,20 +53,10 @@ def test_find_activity_not_found(): def test_find_activity_pr_closed_not_merged(): - with pytest.raises(activity.ActivityNotFound): - activity.find_activity( + with pytest.raises(activities.ActivityNotFound): + activities.find_activity( event_name="pull_request", is_default_branch=False, event_type="closed", is_pr_merged=False, ) - - -def test_validate_activity__invalid(): - with pytest.raises(activity.ActivityConfigError): - activity.validate_activity("invalid") - - -def test_validate_activity__valid(): - result = activity.validate_activity("process_pr") - assert result == activity.Activity.PROCESS_PR diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 314a1dcf..21a53544 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -7,7 +7,7 @@ import pytest -from coverage_comment import settings +from coverage_comment import activities, settings @pytest.mark.parametrize("path", ["a", "a/b/.."]) @@ -51,6 +51,7 @@ def test_config__from_environ__ok(): "ANNOTATION_TYPE": "error", "VERBOSE": "false", "FORCE_WORKFLOW_RUN": "false", + "ACTIVITY": "process_pr", } ) == settings.Config( GITHUB_BASE_REF="master", @@ -75,6 +76,7 @@ def test_config__from_environ__ok(): ANNOTATION_TYPE="error", VERBOSE=False, FORCE_WORKFLOW_RUN=False, + ACTIVITY=activities.Activity.PROCESS_PR, ) @@ -86,7 +88,7 @@ def test_config__verbose_deprecated(get_logs): "GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "master", "GITHUB_EVENT_NAME": "pull", - "GITHUB_EVENT_PATH": pathlib.Path("test_event_path"), + "GITHUB_EVENT_PATH": "test_event_path", "GITHUB_PR_RUN_ID": "123", "GITHUB_STEP_SUMMARY": "step_summary", "VERBOSE": "true", From 6138ab4d25b7cc76029d2a56eb2d8e31d5dba5a7 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Mon, 22 Jun 2026 00:14:55 +0200 Subject: [PATCH 4/5] Improve activity validation --- coverage_comment/activities.py | 6 ------ coverage_comment/main.py | 5 ++--- coverage_comment/settings.py | 8 ++++++-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/coverage_comment/activities.py b/coverage_comment/activities.py index 0a4f5a71..dab3fb3d 100644 --- a/coverage_comment/activities.py +++ b/coverage_comment/activities.py @@ -25,12 +25,6 @@ class ActivityConfigError(Exception): pass -def validate_activity(activity: str) -> Activity: - if activity not in [a.value for a in Activity]: - raise ActivityConfigError(f"Invalid activity: {activity}") - return Activity(activity) - - def find_activity( event_name: str, is_default_branch: bool, diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 34d62c28..20ee229c 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -39,9 +39,8 @@ def action( github=gh, repository=config.GITHUB_REPOSITORY ) try: - if config.ACTIVITY: - activity = activity_module.validate_activity(config.ACTIVITY) - else: + activity = config.ACTIVITY + if not activity: activity = activity_module.find_activity( event_name=event_name, is_default_branch=repo_info.is_default_branch(ref=config.GITHUB_REF), diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py index 21ad6984..3f4f843a 100644 --- a/coverage_comment/settings.py +++ b/coverage_comment/settings.py @@ -10,7 +10,7 @@ from coverage_comment import log -from . import json +from . import activities, json class MissingEnvironmentVariable(Exception): @@ -67,7 +67,7 @@ class Config: ANNOTATION_TYPE: str = "warning" MAX_FILES_IN_COMMENT: int = 25 USE_GH_PAGES_HTML_URL: bool = False - ACTIVITY: str | None = None + ACTIVITY: activities.Activity | None = None VERBOSE: bool = False # Only for debugging, not exposed in the action: FORCE_WORKFLOW_RUN: bool = False @@ -133,6 +133,10 @@ def clean_github_output(cls, value: str) -> pathlib.Path: def clean_github_event_path(cls, value: str) -> pathlib.Path: return pathlib.Path(value) + @classmethod + def clean_activity(cls, activity: str) -> activities.Activity: + return activities.Activity(activity) + @property def GITHUB_PR_NUMBER(self) -> int | None: # "refs/pull/2/merge" From 5589483665ff67f2a218a21db96c45f7414b0778 Mon Sep 17 00:00:00 2001 From: Joachim Jablon Date: Mon, 22 Jun 2026 00:26:16 +0200 Subject: [PATCH 5/5] Add missing test --- coverage_comment/settings.py | 2 +- tests/integration/test_main.py | 71 +++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py index 3f4f843a..4f7aa9ae 100644 --- a/coverage_comment/settings.py +++ b/coverage_comment/settings.py @@ -186,7 +186,7 @@ def FINAL_COVERAGE_DATA_BRANCH(self): # os.environ is, and just saying `dict[str, str]` is not enough to make # mypy happy @classmethod - def from_environ(cls, environ: MutableMapping[str, str]) -> Config: + def from_environ(cls, environ: MutableMapping[str, Any]) -> Config: possible_variables = [e for e in inspect.signature(cls).parameters] config: dict[str, Any] = { k: v for k, v in environ.items() if k in possible_variables diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 271fe460..04b184c5 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -5,7 +5,7 @@ import pytest -from coverage_comment import main +from coverage_comment import activities, main DIFF_STDOUT = """diff --git a/foo.py b/foo.py index 6c08c94..b65c612 100644 @@ -64,6 +64,75 @@ def test_action__invalid_event_name(session, push_config, in_integration_env, ge assert get_logs("ERROR", "This action's default behavior is to determine") +def test_action__explicit_activity( + session, workflow_run_config, in_integration_env, get_logs, zip_bytes +): + session.register( + "GET", + "/repos/py-cov-action/foobar", + json={"default_branch": "main", "visibility": "public"}, + ) + session.register("GET", "/user", json={"login": "foo"}) + session.register( + "GET", + "/repos/py-cov-action/foobar/actions/runs/123", + json={ + "head_branch": "branch", + "head_repository": {"owner": {"login": "bar/repo-name"}}, + }, + ) + + session.register( + "GET", + "/repos/py-cov-action/foobar/pulls", + match_params={ + "head": "bar/repo-name:branch", + "sort": "updated", + "direction": "desc", + "state": "open", + }, + json=[{"number": 456}], + ) + + session.register( + "GET", + "/repos/py-cov-action/foobar/actions/runs/123/artifacts", + match_params={"page": "1"}, + json={ + "artifacts": [{"name": "python-coverage-comment-action", "id": 789}], + "total_count": 1, + }, + ) + + session.register( + "GET", + "/repos/py-cov-action/foobar/actions/artifacts/789/zip", + content=zip_bytes( + filename="python-coverage-comment-action.txt", content="Hey!" + ), + ) + + session.register("GET", "/repos/py-cov-action/foobar/issues/456/comments", json=[]) + + session.register( + "POST", + "/repos/py-cov-action/foobar/issues/456/comments", + json={"body": "Hey!"}, + ) + + result = main.action( + config=workflow_run_config( + GITHUB_EVENT_NAME="something_else", + ACTIVITY=activities.Activity.POST_COMMENT, + ), + github_session=session, + http_session=session, + git=None, + ) + + assert result == 0 + + def get_expected_output( comment_written: bool, reference_coverage: bool ) -> dict[str, str]: