Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,35 @@ the `push` events instead. This is most likely only useful for setups not
accepting external PRs and you will not have the best user experience.
If that's something you need to do, please have a look at [this issue](https://github.com/py-cov-action/python-coverage-comment-action/issues/234).

### Updating the coverage information on the `pull_request/closed` event

Usually, the coverage data for the repository is updated on `push` events to the default
branch, but it can also work to do it on `pull_request/closed` events, especially if
you require all changes to go through a pull request.

In this case, your workflow's `on:` clause should look like this:

```yaml
on:
pull_request:
# opened, synchronize, reopened are the default value
# closed will trigger when the PR is closed (merged or not)
types: [opened, synchronize, reopened, closed]

jobs:
build:
# Optional: if you want to avoid doing the whole build on PRs closed without
# merging, add the following clause. Note that this action won't update the
# coverage data even if you don't specify this (it will raise an error instead),
# but it can help you avoid a useless build.
if: github.event.action != "closed" || github.event.pull_request.merged == true
runs-on: ubuntu-latest
...
```

> [!TIP]
> The action will also save repository coverage data on `schedule` workflows.

## Overriding the template

By default, comments are generated from a
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion coverage_comment/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ class ActivityNotFound(Exception):
def find_activity(
event_name: str,
is_default_branch: bool,
event_type: str,
is_pr_merged: bool,
) -> 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 == "schedule"
or (event_name == "pull_request" and event_type == "closed")
):
if event_name == "pull_request" and event_type == "closed" and not is_pr_merged:
raise ActivityNotFound
return "save_coverage_data_files"

if event_name not in {"pull_request", "push"}:
Expand Down
32 changes: 32 additions & 0 deletions coverage_comment/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import io
import json
import pathlib
import re
import sys
import zipfile
from urllib.parse import urlparse

from coverage_comment import github_client, log

Expand Down Expand Up @@ -46,6 +48,36 @@ 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 (including possible port)
if re.match(r"api\.github\.com(:|$)", netloc):
# Remove 'api.' prefix but keep the port
host_domain = netloc.removeprefix("api.")
# 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,
Expand Down
10 changes: 10 additions & 0 deletions coverage_comment/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ def action(
log.debug(f"Operating on {config.GITHUB_REF}")
gh = github_client.GitHub(session=github_session)
event_name = config.GITHUB_EVENT_NAME

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),
event_type=config.GITHUB_EVENT_TYPE,
is_pr_merged=config.IS_PR_MERGED,
)
except activity_module.ActivityNotFound:
log.error(
Expand Down Expand Up @@ -189,6 +192,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"),
Expand All @@ -208,6 +212,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"),
Expand Down Expand Up @@ -399,19 +404,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,
Expand Down
25 changes: 25 additions & 0 deletions coverage_comment/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import dataclasses
import decimal
import functools
import inspect
import json
import pathlib
from collections.abc import MutableMapping
from typing import Any
Expand Down Expand Up @@ -47,6 +49,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
Expand All @@ -62,6 +65,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
Expand Down Expand Up @@ -123,6 +127,10 @@ def clean_coverage_path(cls, value: str) -> pathlib.Path:
def clean_github_output(cls, value: str) -> pathlib.Path:
return pathlib.Path(value)

@classmethod
def clean_github_event_path(cls, value: str) -> pathlib.Path:
return pathlib.Path(value)

@property
def GITHUB_PR_NUMBER(self) -> int | None:
# "refs/pull/2/merge"
Expand All @@ -137,6 +145,23 @@ def GITHUB_BRANCH_NAME(self) -> str | None:
return self.GITHUB_REF.split("/", 2)[2]
return None

@functools.cached_property
def GITHUB_EVENT_PAYLOAD(self) -> dict:
if not self.GITHUB_EVENT_PATH:
return {}
return json.loads(self.GITHUB_EVENT_PATH.read_text())

@property
def GITHUB_EVENT_TYPE(self) -> str | None:
return self.GITHUB_EVENT_PAYLOAD.get("action")

@property
def IS_PR_MERGED(self) -> bool:
try:
return self.GITHUB_EVENT_PAYLOAD["pull_request"]["merged"]
except KeyError:
return False

@property
def FINAL_COMMENT_FILENAME(self):
filename = self.COMMENT_FILENAME
Expand Down
51 changes: 41 additions & 10 deletions coverage_comment/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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://<user_or_org>.github.io/<repo>/<pages_path>
* GitHub Enterprise => https://<host>/pages/<user_or_org>/<repo>/<pages_path>
- If use_gh_pages_html_url is False:
* GitHub.com => https://htmlpreview.github.io/?<readme_url>
* GitHub Enterprise => <readme_url>
"""
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
6 changes: 4 additions & 2 deletions coverage_comment/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions dev-env
Original file line number Diff line number Diff line change
Expand Up @@ -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(){
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import pathlib
import zipfile
from collections.abc import Callable

import httpx
import pytest
Expand Down Expand Up @@ -47,7 +48,7 @@ def _(**kwargs):


@pytest.fixture
def pull_request_config(base_config):
def pull_request_config(base_config) -> Callable[..., settings.Config]:
def _(**kwargs):
defaults = {
# GitHub stuff
Expand Down Expand Up @@ -250,6 +251,14 @@ def summary_file(tmp_path):
return file


@pytest.fixture
def pull_request_event_payload(tmp_path):
file = tmp_path / "event.json"
file.touch()

return file


_is_failed = []


Expand Down
Loading
Loading