diff --git a/action.yml b/action.yml index a6fa986f..c06ce5e6 100644 --- a/action.yml +++ b/action.yml @@ -102,6 +102,10 @@ inputs: 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 + USE_GH_PAGES_HTML_URL: + description: > + If true, will use the GitHub Pages URL for the coverage report instead of the raw URL or an htmlpreview.github.io link. + default: false VERBOSE: description: > Deprecated, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging diff --git a/coverage_comment/activity.py b/coverage_comment/activity.py index dee9ea8b..1b5507e1 100644 --- a/coverage_comment/activity.py +++ b/coverage_comment/activity.py @@ -16,12 +16,21 @@ class ActivityNotFound(Exception): def find_activity( event_name: str, is_default_branch: bool, + event_action: str | None = None, ) -> str: """Find the activity to perform based on the event type and payload.""" if event_name == "workflow_run": return "post_comment" - if (event_name == "push" and is_default_branch) or event_name == "schedule": + if ( + (event_name == "push" and is_default_branch) + or ( + event_name == "pull_request" + and event_action == "merged" + and is_default_branch + ) + or event_name == "schedule" + ): return "save_coverage_data_files" if event_name not in {"pull_request", "push"}: diff --git a/coverage_comment/github.py b/coverage_comment/github.py index 9a06931e..4b0be198 100644 --- a/coverage_comment/github.py +++ b/coverage_comment/github.py @@ -6,6 +6,7 @@ import pathlib import sys import zipfile +from urllib.parse import urlparse from coverage_comment import github_client, log @@ -46,6 +47,39 @@ def get_repository_info( ) +def extract_github_host(api_url: str) -> str: + """ + Extracts the base GitHub web host URL from a GitHub API URL. + + Args: + api_url: The GitHub API URL (e.g., 'https://api.github.com/...', + 'https://my-ghe.company.com/api/v3/...'). + + Returns: + The base GitHub web host URL (e.g., 'https://github.com', + 'https://my-ghe.company.com'). + """ + parsed_url = urlparse(api_url) + scheme = parsed_url.scheme + netloc = parsed_url.netloc # This includes the domain and potentially the port + + # Special case for GitHub.com API + if netloc == "api.github.com": + host_domain = "github.com" + # Special case for GitHub.com with port (less common but good practice) + elif netloc.startswith("api.github.com:"): + # Remove 'api.' prefix but keep the port + host_domain = netloc.replace("api.", "", 1) + # General case for GitHub Enterprise (netloc is already the host:port) + else: + host_domain = netloc + + # Reconstruct the host URL + host_url = f"{scheme}://{host_domain}" + + return host_url + + def download_artifact( github: github_client.GitHub, repository: str, diff --git a/coverage_comment/main.py b/coverage_comment/main.py index 20f46468..e99415a8 100644 --- a/coverage_comment/main.py +++ b/coverage_comment/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import json import logging import os import sys @@ -69,12 +70,23 @@ def action( log.debug(f"Operating on {config.GITHUB_REF}") gh = github_client.GitHub(session=github_session) event_name = config.GITHUB_EVENT_NAME + event_path = config.GITHUB_EVENT_PATH + event_action = None + + if event_path and os.path.exists(event_path): + with open(event_path) as event_file: + event_payload = json.load(event_file) + is_merged_pr_action = event_payload.get("pull_request", {}).get("merged", False) + if is_merged_pr_action: + event_action = "merged" + repo_info = github.get_repository_info( github=gh, repository=config.GITHUB_REPOSITORY ) try: activity = activity_module.find_activity( event_name=event_name, + event_action=event_action, is_default_branch=repo_info.is_default_branch(ref=config.GITHUB_REF), ) except activity_module.ActivityNotFound: @@ -176,6 +188,7 @@ def process_pr( max_files=config.MAX_FILES_IN_COMMENT, minimum_green=config.MINIMUM_GREEN, minimum_orange=config.MINIMUM_ORANGE, + github_host=github.extract_github_host(config.GITHUB_BASE_URL), repo_name=config.GITHUB_REPOSITORY, pr_number=config.GITHUB_PR_NUMBER, base_template=template.read_template_file("comment.md.j2"), @@ -195,6 +208,7 @@ def process_pr( max_files=None, minimum_green=config.MINIMUM_GREEN, minimum_orange=config.MINIMUM_ORANGE, + github_host=github.extract_github_host(config.GITHUB_BASE_URL), repo_name=config.GITHUB_REPOSITORY, pr_number=config.GITHUB_PR_NUMBER, base_template=template.read_template_file("comment.md.j2"), @@ -386,19 +400,24 @@ def save_coverage_data_files( github_step_summary=config.GITHUB_STEP_SUMMARY, ) + github_host = github.extract_github_host(config.GITHUB_BASE_URL) url_getter = functools.partial( storage.get_raw_file_url, + github_host=github_host, is_public=is_public, repository=config.GITHUB_REPOSITORY, branch=config.FINAL_COVERAGE_DATA_BRANCH, ) readme_url = storage.get_repo_file_url( + github_host=github_host, branch=config.FINAL_COVERAGE_DATA_BRANCH, repository=config.GITHUB_REPOSITORY, ) html_report_url = storage.get_html_report_url( + github_host=github_host, branch=config.FINAL_COVERAGE_DATA_BRANCH, repository=config.GITHUB_REPOSITORY, + use_gh_pages_html_url=config.USE_GH_PAGES_HTML_URL, ) readme_file, log_message = communication.get_readme_and_log( is_public=is_public, diff --git a/coverage_comment/settings.py b/coverage_comment/settings.py index 12473c7d..8c5ed59b 100644 --- a/coverage_comment/settings.py +++ b/coverage_comment/settings.py @@ -47,6 +47,7 @@ class Config: # (from https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables ) GITHUB_REF: str GITHUB_EVENT_NAME: str + GITHUB_EVENT_PATH: pathlib.Path | None = None GITHUB_PR_RUN_ID: int | None GITHUB_STEP_SUMMARY: pathlib.Path COMMENT_TEMPLATE: str | None = None @@ -62,6 +63,7 @@ class Config: ANNOTATE_MISSING_LINES: bool = False ANNOTATION_TYPE: str = "warning" MAX_FILES_IN_COMMENT: int = 25 + USE_GH_PAGES_HTML_URL: bool = False VERBOSE: bool = False # Only for debugging, not exposed in the action: FORCE_WORKFLOW_RUN: bool = False diff --git a/coverage_comment/storage.py b/coverage_comment/storage.py index eb8e49a0..655466d3 100644 --- a/coverage_comment/storage.py +++ b/coverage_comment/storage.py @@ -132,16 +132,17 @@ def get_datafile_contents( def get_raw_file_url( + github_host: str, repository: str, branch: str, path: pathlib.Path, is_public: bool, ): - if not is_public: - # If the repository is private, then the real links to raw.githubusercontents.com - # will be short-lived. In this case, it's better to keep an URL that will - # redirect to the correct URL just when asked. - return f"https://github.com/{repository}/raw/{branch}/{path}" + if (not is_public) or (not github_host.endswith("github.com")): + # If the repository is private or hosted on a github enterprise instance, + # then the real links to raw.githubusercontents.com will be short-lived. + # In this case, it's better to keep an URL that will redirect to the correct URL just when asked. + return f"{github_host}/{repository}/raw/{branch}/{path}" # Otherwise, we can access the file directly. (shields.io doesn't like the # github.com domain) @@ -154,7 +155,9 @@ def get_raw_file_url( # seconds. -def get_repo_file_url(repository: str, branch: str, path: str = "/") -> str: +def get_repo_file_url( + github_host: str, 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, @@ -166,11 +169,39 @@ def get_repo_file_url(repository: str, branch: str, path: str = "/") -> str: # 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("/") + return f"{github_host}/{repository}/{part}/{branch}{path}".rstrip("/") -def get_html_report_url(repository: str, branch: str) -> str: +def get_html_report_url( + github_host: str, + repository: str, + branch: str, + use_gh_pages_html_url: bool = False, +) -> str: + """ + Computes the URL for an HTML report: + - If use_gh_pages_html_url is True: + * GitHub.com => https://.github.io// + * GitHub Enterprise => https:///pages/// + - If use_gh_pages_html_url is False: + * GitHub.com => https://htmlpreview.github.io/? + * GitHub Enterprise => + """ + html_report_path = "htmlcov/index.html" readme_url = get_repo_file_url( - repository=repository, branch=branch, path="/htmlcov/index.html" + github_host, repository=repository, branch=branch, path=html_report_path ) - return f"https://htmlpreview.github.io/?{readme_url}" + + if github_host.endswith("github.com"): + if use_gh_pages_html_url: + user, repo = repository.split("/", 1) + return f"https://{user}.github.io/{repo}/{html_report_path}" + else: + return f"https://htmlpreview.github.io/?{readme_url}" + else: + # Assume GitHub Enterprise + if use_gh_pages_html_url: + return f"{github_host}/pages/{repository}/{html_report_path}" + + # Always fallback to the raw readme_url + return readme_url diff --git a/coverage_comment/template.py b/coverage_comment/template.py index ed680cf5..0db685aa 100644 --- a/coverage_comment/template.py +++ b/coverage_comment/template.py @@ -130,6 +130,7 @@ def get_comment_markdown( count_files: int, minimum_green: decimal.Decimal, minimum_orange: decimal.Decimal, + github_host: str, repo_name: str, pr_number: int, base_template: str, @@ -148,7 +149,7 @@ def get_comment_markdown( env.filters["pluralize"] = pluralize env.filters["compact"] = compact env.filters["file_url"] = functools.partial( - get_file_url, repo_name=repo_name, pr_number=pr_number + get_file_url, github_host=github_host, repo_name=repo_name, pr_number=pr_number ) env.filters["get_badge_color"] = functools.partial( badge.get_badge_color, @@ -304,11 +305,12 @@ def get_file_url( filename: pathlib.Path, lines: tuple[int, int] | None = None, *, + github_host: str, repo_name: str, pr_number: int, ) -> str: # To link to a file in a PR, GitHub uses the link to the file overview combined with a SHA256 hash of the file path - s = f"https://github.com/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}" + s = f"{github_host}/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}" if lines is not None: # R stands for Right side of the diff. But since we generate these links for new code we only need the right side. diff --git a/dev-env b/dev-env index 102d3146..ddcda718 100755 --- a/dev-env +++ b/dev-env @@ -47,7 +47,7 @@ function create-repo(){ repo_dirname=$(basename ${GITHUB_REPOSITORY}) mv "${repo_dirname}/"{*,.*} . rmdir "${repo_dirname}" - git pull --ff-only origin master + git pull --ff-only origin main } function delete-repo(){ @@ -142,7 +142,7 @@ function help(){ echo " coverage_comment" >&2 echo " Launch the action locally (no argument)" >&2 echo " pytest" >&2 - echo " Launch the the tests on the example repo (generates the coverage data that the action uses)" >&2 + echo " Launch the tests on the example repo (generates the coverage data that the action uses)" >&2 echo "" >&2 echo "Change configuration:" >&2 diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py index a5b99ec1..01d727ea 100644 --- a/tests/integration/test_github.py +++ b/tests/integration/test_github.py @@ -46,6 +46,27 @@ def test_get_repository_info(gh, session): assert info == github.RepositoryInfo(default_branch="baz", visibility="public") +@pytest.mark.parametrize( + "api_url, expected", + [ + ("https://api.github.com/repos/foo/bar", "https://github.com"), + ("https://api.github.com:8080/repos/foo/bar", "https://github.com:8080"), + ("https://api.github.com/repos/foo/bar/issues", "https://github.com"), + ( + "https://my-ghe.company.com/api/v3/repos/foo/bar", + "https://my-ghe.company.com", + ), + ( + "https://my-ghe.company.com/api/v3/repos/foo/bar/issues", + "https://my-ghe.company.com", + ), + ], +) +def test_extract_github_host(api_url, expected): + result = github.extract_github_host(api_url=api_url) + assert result == expected + + def test_download_artifact(gh, session, zip_bytes): artifacts = [ {"name": "bar", "id": 456}, diff --git a/tests/unit/test_activity.py b/tests/unit/test_activity.py index 06d267c2..d54b49e9 100644 --- a/tests/unit/test_activity.py +++ b/tests/unit/test_activity.py @@ -6,18 +6,22 @@ @pytest.mark.parametrize( - "event_name, is_default_branch, expected_activity", + "event_name, event_action, is_default_branch, expected_activity", [ - ("workflow_run", True, "post_comment"), - ("push", True, "save_coverage_data_files"), - ("push", False, "process_pr"), - ("pull_request", True, "process_pr"), - ("pull_request", False, "process_pr"), + ("workflow_run", None, True, "post_comment"), + ("push", None, True, "save_coverage_data_files"), + ("push", None, False, "process_pr"), + ("pull_request", "merged", True, "save_coverage_data_files"), + ("pull_request", None, True, "process_pr"), + ("pull_request", None, False, "process_pr"), + ("schedule", None, False, "save_coverage_data_files"), ], ) -def test_find_activity(event_name, is_default_branch, expected_activity): +def test_find_activity(event_name, event_action, is_default_branch, expected_activity): result = activity.find_activity( - event_name=event_name, is_default_branch=is_default_branch + event_name=event_name, + event_action=event_action, + is_default_branch=is_default_branch, ) assert result == expected_activity diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index b283d991..720263c7 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -33,6 +33,7 @@ def test_config__from_environ__ok(): "GITHUB_REF": "master", "GITHUB_OUTPUT": "foo.txt", "GITHUB_EVENT_NAME": "pull", + "GITHUB_EVENT_PATH": pathlib.Path("test_event_path"), "GITHUB_PR_RUN_ID": "123", "GITHUB_STEP_SUMMARY": "step_summary", "COMMENT_ARTIFACT_NAME": "baz", @@ -56,6 +57,7 @@ def test_config__from_environ__ok(): GITHUB_REF="master", GITHUB_OUTPUT=pathlib.Path("foo.txt"), GITHUB_EVENT_NAME="pull", + GITHUB_EVENT_PATH=pathlib.Path("test_event_path"), GITHUB_PR_RUN_ID=123, GITHUB_STEP_SUMMARY=pathlib.Path("step_summary"), COMMENT_ARTIFACT_NAME="baz", @@ -82,6 +84,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_PR_RUN_ID": "123", "GITHUB_STEP_SUMMARY": "step_summary", "VERBOSE": "true", @@ -92,6 +95,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_PR_RUN_ID=123, GITHUB_STEP_SUMMARY=pathlib.Path("step_summary"), VERBOSE=False, @@ -107,6 +111,7 @@ def config(): "GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "master", "GITHUB_EVENT_NAME": "pull", + "GITHUB_EVENT_PATH": pathlib.Path("test_event_path"), "GITHUB_PR_RUN_ID": 123, "GITHUB_STEP_SUMMARY": pathlib.Path("step_summary"), "COMMENT_ARTIFACT_NAME": "baz", diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 2ba72a80..dc359491 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -154,14 +154,24 @@ def test_get_datafile_contents(gh, session): @pytest.mark.parametrize( - "is_public, expected", + "github_host, is_public, expected", [ - (False, "https://github.com/foo/bar/raw/baz/qux"), - (True, "https://raw.githubusercontent.com/foo/bar/baz/qux"), + ("https://github.com", False, "https://github.com/foo/bar/raw/baz/qux"), + ( + "https://github.com", + True, + "https://raw.githubusercontent.com/foo/bar/baz/qux", + ), + ( + "https://github.mycompany.com", + True, + "https://github.mycompany.com/foo/bar/raw/baz/qux", + ), ], ) -def test_get_raw_file_url(is_public, expected): +def test_get_raw_file_url(github_host, is_public, expected): result = storage.get_raw_file_url( + github_host=github_host, repository="foo/bar", branch="baz", path=pathlib.Path("qux"), @@ -171,29 +181,81 @@ def test_get_raw_file_url(is_public, expected): @pytest.mark.parametrize( - "path, expected", + "github_host, 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"), + ("https://github.com", "", "https://github.com/foo/bar/tree/baz"), + ("https://github.com", "/", "https://github.com/foo/bar/tree/baz"), + ( + "https://github.com", + "qux", + "https://github.com/foo/bar/blob/baz/qux", + ), # blob + ("https://github.com", "qux/", "https://github.com/foo/bar/tree/baz/qux"), + ( + "https://github.mycompany.com", + "/qux", + "https://github.mycompany.com/foo/bar/blob/baz/qux", + ), # blob + ( + "https://github.mycompany.com", + "/qux/", + "https://github.mycompany.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) +def test_get_repo_file_url(github_host, path, expected): + result = storage.get_repo_file_url( + github_host=github_host, 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") +@pytest.mark.parametrize( + "github_host", + [ + "https://github.com", + "https://github.mycompany.com", + ], +) +def test_get_repo_file_url__no_path(github_host): + result = storage.get_repo_file_url( + github_host=github_host, repository="foo/bar", branch="baz" + ) - assert result == "https://github.com/foo/bar/tree/baz" + assert result == f"{github_host}/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" +@pytest.mark.parametrize( + "github_host,use_gh_pages_html_url,expected", + [ + ( + "https://github.com", + True, + "https://foo.github.io/bar/htmlcov/index.html", + ), + ( + "https://github.com", + False, + "https://htmlpreview.github.io/?https://github.com/foo/bar/blob/baz/htmlcov/index.html", + ), + ( + "https://github.mycompany.com", + True, + "https://github.mycompany.com/pages/foo/bar/htmlcov/index.html", + ), + ( + "https://github.mycompany.com", + False, + "https://github.mycompany.com/foo/bar/blob/baz/htmlcov/index.html", + ), + ], +) +def test_get_html_report_url(github_host, use_gh_pages_html_url, expected): + result = storage.get_html_report_url( + github_host=github_host, + repository="foo/bar", + branch="baz", + use_gh_pages_html_url=use_gh_pages_html_url, + ) assert result == expected diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py index 521b5dc8..f516baad 100644 --- a/tests/unit/test_template.py +++ b/tests/unit/test_template.py @@ -27,6 +27,7 @@ def test_get_comment_markdown(coverage_obj, diff_coverage_obj): minimum_green=decimal.Decimal("100"), minimum_orange=decimal.Decimal("70"), marker="", + github_host="https://github.com", repo_name="org/repo", pr_number=1, base_template=""" @@ -66,6 +67,7 @@ def test_template(coverage_obj, diff_coverage_obj): files=files, count_files=total, max_files=25, + github_host="https://github.com", repo_name="org/repo", pr_number=5, base_template=template.read_template_file("comment.md.j2"), @@ -195,6 +197,7 @@ def test_template_full(make_coverage, make_coverage_and_diff): minimum_green=decimal.Decimal("100"), minimum_orange=decimal.Decimal("70"), marker="", + github_host="https://github.com", repo_name="org/repo", pr_number=12, base_template=template.read_template_file("comment.md.j2"), @@ -257,6 +260,7 @@ def test_template__no_previous(coverage_obj_no_branch, diff_coverage_obj): minimum_green=decimal.Decimal("100"), minimum_orange=decimal.Decimal("70"), marker="", + github_host="https://github.com", repo_name="org/repo", pr_number=3, base_template=template.read_template_file("comment.md.j2"), @@ -309,6 +313,7 @@ def test_template__max_files(coverage_obj_more_files, diff_coverage_obj_more_fil previous_coverage_rate=decimal.Decimal("0.92"), minimum_green=decimal.Decimal("79"), minimum_orange=decimal.Decimal("40"), + github_host="https://github.com", repo_name="org/repo", pr_number=5, max_files=1, @@ -340,6 +345,7 @@ def test_template__no_max_files(coverage_obj_more_files, diff_coverage_obj_more_ previous_coverage_rate=decimal.Decimal("0.92"), minimum_green=decimal.Decimal("79"), minimum_orange=decimal.Decimal("40"), + github_host="https://github.com", repo_name="org/repo", pr_number=5, max_files=None, @@ -374,6 +380,7 @@ def test_template__no_files(coverage_obj, diff_coverage_obj_more_files): previous_coverage_rate=decimal.Decimal("0.92"), minimum_green=decimal.Decimal("79"), minimum_orange=decimal.Decimal("40"), + github_host="https://github.com", repo_name="org/repo", pr_number=5, max_files=25, @@ -412,6 +419,7 @@ def test_template__no_marker(coverage_obj, diff_coverage_obj): previous_coverage_rate=decimal.Decimal("0.92"), minimum_green=decimal.Decimal("100"), minimum_orange=decimal.Decimal("70"), + github_host="https://github.com", repo_name="org/repo", pr_number=1, base_template=template.read_template_file("comment.md.j2"), @@ -432,6 +440,7 @@ def test_template__broken_template(coverage_obj, diff_coverage_obj): previous_coverage_rate=decimal.Decimal("0.92"), minimum_green=decimal.Decimal("100"), minimum_orange=decimal.Decimal("70"), + github_host="https://github.com", repo_name="org/repo", pr_number=1, base_template=template.read_template_file("comment.md.j2"), @@ -496,6 +505,7 @@ def test_get_file_url(filepath, lines, expected): result = template.get_file_url( filename=filepath, lines=lines, + github_host="https://github.com", repo_name="py-cov-action/python-coverage-comment-action", pr_number=33, )