From f636f2d9d4c48325e9d2ea132e377ebcb99626b5 Mon Sep 17 00:00:00 2001 From: Dave Golombek Date: Sun, 26 Oct 2025 21:30:03 -0400 Subject: [PATCH 1/2] 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 3e4ea124..86325953 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 @@ -469,6 +499,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 094c55af..f12e5194 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -74,22 +74,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, @@ -97,7 +102,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, @@ -105,7 +110,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 731fa6bc..68c43e49 100644 --- a/coverage_comment/settings.py +++ b/coverage_comment/settings.py @@ -66,6 +66,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 a7b9c610..0012907e 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -59,7 +59,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 2ef45c22fe204875795159114fc99127db9950f5 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/2] [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 f12e5194..e893f150 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -85,11 +85,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