diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 6f3c88a6..1d713121 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -5,6 +5,7 @@ import httpx +from coverage_comment import activity as activity_module from coverage_comment import annotations, comment_file, communication from coverage_comment import coverage as coverage_module from coverage_comment import ( @@ -60,9 +61,17 @@ def action( git: subprocess.Git, ) -> int: log.debug(f"Operating on {config.GITHUB_REF}") - + gh = github_client.GitHub(session=github_session) event_name = config.GITHUB_EVENT_NAME - if event_name not in {"pull_request", "push", "workflow_run"}: + repo_info = github.get_repository_info( + 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), + ) + except activity_module.ActivityNotFound: log.error( 'This action has only been designed to work for "pull_request", "push" ' f'or "workflow_run" actions, not "{event_name}". Because there are security ' @@ -71,51 +80,49 @@ def action( ) return 1 - if event_name in {"pull_request", "push"}: - coverage = coverage_module.get_coverage_info( - merge=config.MERGE_COVERAGE_FILES, coverage_path=config.COVERAGE_PATH + if activity == "save_coverage_data_files": + return save_coverage_data_files( + config=config, + git=git, + http_session=http_session, + repo_info=repo_info, + ) + + elif activity == "process_pr": + return process_pr( + config=config, + gh=gh, + repo_info=repo_info, ) - if event_name == "pull_request": - diff_coverage = coverage_module.get_diff_coverage_info( - base_ref=config.GITHUB_BASE_REF, coverage_path=config.COVERAGE_PATH - ) - if config.ANNOTATE_MISSING_LINES: - annotations.create_pr_annotations( - annotation_type=config.ANNOTATION_TYPE, diff_coverage=diff_coverage - ) - return generate_comment( - config=config, - coverage=coverage, - diff_coverage=diff_coverage, - github_session=github_session, - ) - else: - # event_name == "push" - return save_coverage_data_files( - config=config, - coverage=coverage, - github_session=github_session, - git=git, - http_session=http_session, - ) else: - # event_name == "workflow_run" + # activity == "post_comment": return post_comment( config=config, - github_session=github_session, + gh=gh, ) -def generate_comment( +def process_pr( config: settings.Config, - coverage: coverage_module.Coverage, - diff_coverage: coverage_module.DiffCoverage, - github_session: httpx.Client, + gh: github_client.GitHub, + repo_info: github.RepositoryInfo, ) -> int: log.info("Generating comment for PR") - gh = github_client.GitHub(session=github_session) + coverage = coverage_module.get_coverage_info( + merge=config.MERGE_COVERAGE_FILES, + coverage_path=config.COVERAGE_PATH, + ) + base_ref = config.GITHUB_BASE_REF or repo_info.default_branch + diff_coverage = coverage_module.get_diff_coverage_info( + base_ref=base_ref, coverage_path=config.COVERAGE_PATH + ) + + if config.ANNOTATE_MISSING_LINES: + annotations.create_pr_annotations( + annotation_type=config.ANNOTATION_TYPE, diff_coverage=diff_coverage + ) previous_coverage_data_file = storage.get_datafile_contents( github=gh, @@ -151,21 +158,35 @@ def generate_comment( ) return 1 - assert config.GITHUB_PR_NUMBER - github.add_job_summary( content=comment, github_step_summary=config.GITHUB_STEP_SUMMARY ) + pr_number: int | None = config.GITHUB_PR_NUMBER + if not pr_number: + # If we don't have a PR number, we're launched from a push event, + # so we need to find the PR number from the branch name + assert config.GITHUB_BRANCH_NAME + try: + pr_number = github.find_pr_for_branch( + github=gh, + # A push event cannot be initiated from a forked repository + repository=config.GITHUB_REPOSITORY, + owner=config.GITHUB_REPOSITORY.split("/")[0], + branch=config.GITHUB_BRANCH_NAME, + ) + except github.CannotDeterminePR: + pr_number = None + try: - if config.FORCE_WORKFLOW_RUN: + if config.FORCE_WORKFLOW_RUN or not pr_number: raise github.CannotPostComment github.post_comment( github=gh, me=github.get_my_login(github=gh), repository=config.GITHUB_REPOSITORY, - pr_number=config.GITHUB_PR_NUMBER, + pr_number=pr_number, contents=comment, marker=template.MARKER, ) @@ -192,21 +213,29 @@ def generate_comment( return 0 -def post_comment(config: settings.Config, github_session: httpx.Client) -> int: +def post_comment( + config: settings.Config, + gh: github_client.GitHub, +) -> int: log.info("Posting comment to PR") if not config.GITHUB_PR_RUN_ID: log.error("Missing input GITHUB_PR_RUN_ID. Please consult the documentation.") return 1 - gh = github_client.GitHub(session=github_session) me = github.get_my_login(github=gh) log.info(f"Search for PR associated with run id {config.GITHUB_PR_RUN_ID}") + owner, branch = github.get_branch_from_workflow_run( + github=gh, + run_id=config.GITHUB_PR_RUN_ID, + repository=config.GITHUB_REPOSITORY, + ) try: - pr_number = github.get_pr_number_from_workflow_run( + pr_number = github.find_pr_for_branch( github=gh, - run_id=config.GITHUB_PR_RUN_ID, repository=config.GITHUB_REPOSITORY, + owner=owner, + branch=branch, ) except github.CannotDeterminePR: log.error( @@ -249,24 +278,17 @@ def post_comment(config: settings.Config, github_session: httpx.Client) -> int: def save_coverage_data_files( config: settings.Config, - coverage: coverage_module.Coverage, - github_session: httpx.Client, git: subprocess.Git, http_session: httpx.Client, + repo_info: github.RepositoryInfo, ) -> int: - gh = github_client.GitHub(session=github_session) - repo_info = github.get_repository_info( - github=gh, - repository=config.GITHUB_REPOSITORY, - ) - is_default_branch = repo_info.is_default_branch(ref=config.GITHUB_REF) - log.debug(f"On default branch: {is_default_branch}") + log.info("Computing coverage files & badge") - if not is_default_branch: - log.info("Skipping badge save as we're not on the default branch") - return 0 + coverage = coverage_module.get_coverage_info( + merge=config.MERGE_COVERAGE_FILES, + coverage_path=config.COVERAGE_PATH, + ) - log.info("Computing coverage files & badge") operations: list[files.Operation] = files.compute_files( line_rate=coverage.info.percent_covered, minimum_green=config.MINIMUM_GREEN, diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 8481801c..f25f438f 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -96,9 +96,28 @@ def integration_env(integration_dir, write_file, run_coverage, commit): subprocess.check_call(["git", "fetch", "origin"], cwd=integration_dir) +def test_action__invalid_event_name(session, push_config, in_integration_env, get_logs): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + + result = main.action( + config=push_config(GITHUB_EVENT_NAME="pull_request_target"), + github_session=session, + http_session=session, + git=None, + ) + + assert result == 1 + assert get_logs("ERROR", "This action has only been designed to work for") + + def test_action__pull_request__store_comment( pull_request_config, session, in_integration_env, output_file, summary_file, capsys ): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) # No existing badge in this test session.register( "GET", @@ -161,6 +180,10 @@ def checker(payload): def test_action__pull_request__post_comment( pull_request_config, session, in_integration_env, output_file, summary_file ): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + payload = json.dumps({"coverage": 30.00}) # There is an existing badge in this test, allowing to test the coverage evolution session.register( @@ -210,9 +233,135 @@ def checker(payload): assert output_file.read_text() == expected_output +def test_action__push__non_default_branch( + push_config, session, in_integration_env, output_file, summary_file +): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + + payload = json.dumps({"coverage": 30.00}) + # There is an existing badge in this test, allowing to test the coverage evolution + session.register( + "GET", + "/repos/py-cov-action/foobar/contents/data.json", + )(json={"content": base64.b64encode(payload.encode()).decode()}) + + session.register( + "GET", + "/repos/py-cov-action/foobar/pulls", + params={ + "state": "open", + "head": "py-cov-action:other", + "sort": "updated", + "direction": "desc", + }, + )(json=[{"number": 2}]) + + # Who am I + session.register("GET", "/user")(json={"login": "foo"}) + # Are there already comments + session.register("GET", "/repos/py-cov-action/foobar/issues/2/comments")(json=[]) + + comment = None + + def checker(payload): + body = payload["body"] + assert "## Coverage report" in body + nonlocal comment + comment = body + return True + + # Post a new comment + session.register( + "POST", + "/repos/py-cov-action/foobar/issues/2/comments", + json=checker, + )( + status_code=200, + ) + result = main.action( + config=push_config( + GITHUB_REF="refs/heads/other", + GITHUB_STEP_SUMMARY=summary_file, + GITHUB_OUTPUT=output_file, + ), + github_session=session, + http_session=session, + git=None, + ) + assert result == 0 + + assert not pathlib.Path("python-coverage-comment-action.txt").exists() + assert "The coverage rate went from `30%` to `77.77%` :arrow_up:" in comment + assert comment == summary_file.read_text() + + expected_output = "COMMENT_FILE_WRITTEN=false\n" + + assert output_file.read_text() == expected_output + + +def test_action__push__non_default_branch__no_pr( + push_config, session, in_integration_env, output_file, summary_file +): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + + payload = json.dumps({"coverage": 30.00}) + # There is an existing badge in this test, allowing to test the coverage evolution + session.register( + "GET", + "/repos/py-cov-action/foobar/contents/data.json", + )(json={"content": base64.b64encode(payload.encode()).decode()}) + + session.register( + "GET", + "/repos/py-cov-action/foobar/pulls", + params={ + "state": "open", + "head": "py-cov-action:other", + "sort": "updated", + "direction": "desc", + }, + )(json=[]) + session.register( + "GET", + "/repos/py-cov-action/foobar/pulls", + params={ + "state": "all", + "head": "py-cov-action:other", + "sort": "updated", + "direction": "desc", + }, + )(json=[]) + + result = main.action( + config=push_config( + GITHUB_REF="refs/heads/other", + GITHUB_STEP_SUMMARY=summary_file, + GITHUB_OUTPUT=output_file, + ), + github_session=session, + http_session=session, + git=None, + ) + assert result == 0 + + assert pathlib.Path("python-coverage-comment-action.txt").exists() + + expected_output = "COMMENT_FILE_WRITTEN=true\n" + + assert output_file.read_text() == expected_output + + def test_action__pull_request__force_store_comment( pull_request_config, session, in_integration_env, output_file ): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + payload = json.dumps({"coverage": 30.00}) # There is an existing badge in this test, allowing to test the coverage evolution session.register( @@ -238,6 +387,10 @@ def test_action__pull_request__force_store_comment( def test_action__pull_request__post_comment__no_marker( pull_request_config, session, in_integration_env, get_logs ): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + # No existing badge in this test session.register( "GET", @@ -257,6 +410,9 @@ def test_action__pull_request__post_comment__no_marker( def test_action__pull_request__annotations( pull_request_config, session, in_integration_env, capsys ): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) # No existing badge in this test session.register( "GET", @@ -292,6 +448,10 @@ def test_action__pull_request__annotations( def test_action__pull_request__post_comment__template_error( pull_request_config, session, in_integration_env, get_logs ): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + # No existing badge in this test session.register( "GET", @@ -308,24 +468,6 @@ def test_action__pull_request__post_comment__template_error( assert get_logs("ERROR", "There was a rendering error") -def test_action__push__non_default_branch( - push_config, session, in_integration_env, get_logs -): - session.register("GET", "/repos/py-cov-action/foobar")( - json={"default_branch": "main", "visibility": "public"} - ) - - result = main.action( - config=push_config(GITHUB_REF="refs/heads/master"), - github_session=session, - http_session=session, - git=None, - ) - assert result == 0 - - assert get_logs("INFO", "Skipping badge") - - def test_action__push__default_branch( push_config, session, in_integration_env, get_logs, git, summary_file ): @@ -435,14 +577,35 @@ def test_action__push__default_branch__private( assert log == expected +def test_action__workflow_run__no_pr_number( + workflow_run_config, session, in_integration_env, get_logs +): + session.register("GET", "/repos/py-cov-action/foobar")( + json={"default_branch": "main", "visibility": "public"} + ) + + result = main.action( + config=workflow_run_config(GITHUB_PR_RUN_ID=None), + github_session=session, + http_session=session, + git=None, + ) + + assert result == 1 + assert get_logs("ERROR", "Missing input GITHUB_PR_RUN_ID") + + def test_action__workflow_run__no_pr( workflow_run_config, session, in_integration_env, get_logs ): + 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": {"full_name": "bar/repo-name"}, + "head_repository": {"owner": {"login": "bar/repo-name"}}, } ) @@ -481,11 +644,14 @@ def test_action__workflow_run__no_pr( def test_action__workflow_run__no_artifact( workflow_run_config, session, in_integration_env, get_logs ): + 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": {"full_name": "bar/repo-name"}, + "head_repository": {"owner": {"login": "bar/repo-name"}}, } ) @@ -519,11 +685,14 @@ def test_action__workflow_run__no_artifact( def test_action__workflow_run__post_comment( workflow_run_config, session, in_integration_env, get_logs, zip_bytes, summary_file ): + 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": {"full_name": "bar/repo-name"}, + "head_repository": {"owner": {"login": "bar/repo-name"}}, } ) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index a98b4391..28d45a05 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5,28 +5,6 @@ from coverage_comment import main, settings, subprocess -def test_action__invalid_event_name(push_config, get_logs): - result = main.action( - config=push_config(GITHUB_EVENT_NAME="pull_request_target"), - github_session=None, - http_session=None, - git=None, - ) - - assert result == 1 - assert get_logs("ERROR", "This action has only been designed to work for") - - -def test_post_comment__no_run_id(workflow_run_config, get_logs): - result = main.post_comment( - config=workflow_run_config(GITHUB_PR_RUN_ID=""), - github_session=None, - ) - - assert result == 1 - assert get_logs("ERROR", "Missing input GITHUB_PR_RUN_ID") - - def test_main(mocker, get_logs): # 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