Skip to content

Commit 47f98f1

Browse files
authored
Merge pull request #589 from py-cov-action/additional-support-for-gh-enterprise
2 parents eeefd7d + 370c909 commit 47f98f1

18 files changed

+394
-53
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,35 @@ the `push` events instead. This is most likely only useful for setups not
391391
accepting external PRs and you will not have the best user experience.
392392
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).
393393

394+
### Updating the coverage information on the `pull_request/closed` event
395+
396+
Usually, the coverage data for the repository is updated on `push` events to the default
397+
branch, but it can also work to do it on `pull_request/closed` events, especially if
398+
you require all changes to go through a pull request.
399+
400+
In this case, your workflow's `on:` clause should look like this:
401+
402+
```yaml
403+
on:
404+
pull_request:
405+
# opened, synchronize, reopened are the default value
406+
# closed will trigger when the PR is closed (merged or not)
407+
types: [opened, synchronize, reopened, closed]
408+
409+
jobs:
410+
build:
411+
# Optional: if you want to avoid doing the whole build on PRs closed without
412+
# merging, add the following clause. Note that this action won't update the
413+
# coverage data even if you don't specify this (it will raise an error instead),
414+
# but it can help you avoid a useless build.
415+
if: github.event.action != "closed" || github.event.pull_request.merged == true
416+
runs-on: ubuntu-latest
417+
...
418+
```
419+
420+
> [!TIP]
421+
> The action will also save repository coverage data on `schedule` workflows.
422+
394423
## Overriding the template
395424

396425
By default, comments are generated from a

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ inputs:
102102
notice, warning and error as annotation type. For more information look here:
103103
https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-notice-message
104104
default: warning
105+
USE_GH_PAGES_HTML_URL:
106+
description: >
107+
If true, will use the GitHub Pages URL for the coverage report instead of the raw URL or an htmlpreview.github.io link.
108+
default: false
105109
VERBOSE:
106110
description: >
107111
Deprecated, see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging

coverage_comment/activity.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,20 @@ class ActivityNotFound(Exception):
1616
def find_activity(
1717
event_name: str,
1818
is_default_branch: bool,
19+
event_type: str,
20+
is_pr_merged: bool,
1921
) -> str:
2022
"""Find the activity to perform based on the event type and payload."""
2123
if event_name == "workflow_run":
2224
return "post_comment"
2325

24-
if (event_name == "push" and is_default_branch) or event_name == "schedule":
26+
if (
27+
(event_name == "push" and is_default_branch)
28+
or event_name == "schedule"
29+
or (event_name == "pull_request" and event_type == "closed")
30+
):
31+
if event_name == "pull_request" and event_type == "closed" and not is_pr_merged:
32+
raise ActivityNotFound
2533
return "save_coverage_data_files"
2634

2735
if event_name not in {"pull_request", "push"}:

coverage_comment/github.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import io
55
import json
66
import pathlib
7+
import re
78
import sys
89
import zipfile
10+
from urllib.parse import urlparse
911

1012
from coverage_comment import github_client, log
1113

@@ -46,6 +48,36 @@ def get_repository_info(
4648
)
4749

4850

51+
def extract_github_host(api_url: str) -> str:
52+
"""
53+
Extracts the base GitHub web host URL from a GitHub API URL.
54+
55+
Args:
56+
api_url: The GitHub API URL (e.g., 'https://api.github.com/...',
57+
'https://my-ghe.company.com/api/v3/...').
58+
59+
Returns:
60+
The base GitHub web host URL (e.g., 'https://github.com',
61+
'https://my-ghe.company.com').
62+
"""
63+
parsed_url = urlparse(api_url)
64+
scheme = parsed_url.scheme
65+
netloc = parsed_url.netloc # This includes the domain and potentially the port
66+
67+
# Special case for GitHub.com API (including possible port)
68+
if re.match(r"api\.github\.com(:|$)", netloc):
69+
# Remove 'api.' prefix but keep the port
70+
host_domain = netloc.removeprefix("api.")
71+
# General case for GitHub Enterprise (netloc is already the host:port)
72+
else:
73+
host_domain = netloc
74+
75+
# Reconstruct the host URL
76+
host_url = f"{scheme}://{host_domain}"
77+
78+
return host_url
79+
80+
4981
def download_artifact(
5082
github: github_client.GitHub,
5183
repository: str,

coverage_comment/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,16 @@ def action(
6969
log.debug(f"Operating on {config.GITHUB_REF}")
7070
gh = github_client.GitHub(session=github_session)
7171
event_name = config.GITHUB_EVENT_NAME
72+
7273
repo_info = github.get_repository_info(
7374
github=gh, repository=config.GITHUB_REPOSITORY
7475
)
7576
try:
7677
activity = activity_module.find_activity(
7778
event_name=event_name,
7879
is_default_branch=repo_info.is_default_branch(ref=config.GITHUB_REF),
80+
event_type=config.GITHUB_EVENT_TYPE,
81+
is_pr_merged=config.IS_PR_MERGED,
7982
)
8083
except activity_module.ActivityNotFound:
8184
log.error(
@@ -189,6 +192,7 @@ def process_pr(
189192
max_files=config.MAX_FILES_IN_COMMENT,
190193
minimum_green=config.MINIMUM_GREEN,
191194
minimum_orange=config.MINIMUM_ORANGE,
195+
github_host=github.extract_github_host(config.GITHUB_BASE_URL),
192196
repo_name=config.GITHUB_REPOSITORY,
193197
pr_number=config.GITHUB_PR_NUMBER,
194198
base_template=template.read_template_file("comment.md.j2"),
@@ -208,6 +212,7 @@ def process_pr(
208212
max_files=None,
209213
minimum_green=config.MINIMUM_GREEN,
210214
minimum_orange=config.MINIMUM_ORANGE,
215+
github_host=github.extract_github_host(config.GITHUB_BASE_URL),
211216
repo_name=config.GITHUB_REPOSITORY,
212217
pr_number=config.GITHUB_PR_NUMBER,
213218
base_template=template.read_template_file("comment.md.j2"),
@@ -399,19 +404,24 @@ def save_coverage_data_files(
399404
github_step_summary=config.GITHUB_STEP_SUMMARY,
400405
)
401406

407+
github_host = github.extract_github_host(config.GITHUB_BASE_URL)
402408
url_getter = functools.partial(
403409
storage.get_raw_file_url,
410+
github_host=github_host,
404411
is_public=is_public,
405412
repository=config.GITHUB_REPOSITORY,
406413
branch=config.FINAL_COVERAGE_DATA_BRANCH,
407414
)
408415
readme_url = storage.get_repo_file_url(
416+
github_host=github_host,
409417
branch=config.FINAL_COVERAGE_DATA_BRANCH,
410418
repository=config.GITHUB_REPOSITORY,
411419
)
412420
html_report_url = storage.get_html_report_url(
421+
github_host=github_host,
413422
branch=config.FINAL_COVERAGE_DATA_BRANCH,
414423
repository=config.GITHUB_REPOSITORY,
424+
use_gh_pages_html_url=config.USE_GH_PAGES_HTML_URL,
415425
)
416426
readme_file, log_message = communication.get_readme_and_log(
417427
is_public=is_public,

coverage_comment/settings.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import dataclasses
44
import decimal
5+
import functools
56
import inspect
7+
import json
68
import pathlib
79
from collections.abc import MutableMapping
810
from typing import Any
@@ -47,6 +49,7 @@ class Config:
4749
# (from https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables )
4850
GITHUB_REF: str
4951
GITHUB_EVENT_NAME: str
52+
GITHUB_EVENT_PATH: pathlib.Path | None = None
5053
GITHUB_PR_RUN_ID: int | None
5154
GITHUB_STEP_SUMMARY: pathlib.Path
5255
COMMENT_TEMPLATE: str | None = None
@@ -62,6 +65,7 @@ class Config:
6265
ANNOTATE_MISSING_LINES: bool = False
6366
ANNOTATION_TYPE: str = "warning"
6467
MAX_FILES_IN_COMMENT: int = 25
68+
USE_GH_PAGES_HTML_URL: bool = False
6569
VERBOSE: bool = False
6670
# Only for debugging, not exposed in the action:
6771
FORCE_WORKFLOW_RUN: bool = False
@@ -123,6 +127,10 @@ def clean_coverage_path(cls, value: str) -> pathlib.Path:
123127
def clean_github_output(cls, value: str) -> pathlib.Path:
124128
return pathlib.Path(value)
125129

130+
@classmethod
131+
def clean_github_event_path(cls, value: str) -> pathlib.Path:
132+
return pathlib.Path(value)
133+
126134
@property
127135
def GITHUB_PR_NUMBER(self) -> int | None:
128136
# "refs/pull/2/merge"
@@ -137,6 +145,23 @@ def GITHUB_BRANCH_NAME(self) -> str | None:
137145
return self.GITHUB_REF.split("/", 2)[2]
138146
return None
139147

148+
@functools.cached_property
149+
def GITHUB_EVENT_PAYLOAD(self) -> dict:
150+
if not self.GITHUB_EVENT_PATH:
151+
return {}
152+
return json.loads(self.GITHUB_EVENT_PATH.read_text())
153+
154+
@property
155+
def GITHUB_EVENT_TYPE(self) -> str | None:
156+
return self.GITHUB_EVENT_PAYLOAD.get("action")
157+
158+
@property
159+
def IS_PR_MERGED(self) -> bool:
160+
try:
161+
return self.GITHUB_EVENT_PAYLOAD["pull_request"]["merged"]
162+
except KeyError:
163+
return False
164+
140165
@property
141166
def FINAL_COMMENT_FILENAME(self):
142167
filename = self.COMMENT_FILENAME

coverage_comment/storage.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,17 @@ def get_datafile_contents(
132132

133133

134134
def get_raw_file_url(
135+
github_host: str,
135136
repository: str,
136137
branch: str,
137138
path: pathlib.Path,
138139
is_public: bool,
139140
):
140-
if not is_public:
141-
# If the repository is private, then the real links to raw.githubusercontents.com
142-
# will be short-lived. In this case, it's better to keep an URL that will
143-
# redirect to the correct URL just when asked.
144-
return f"https://github.com/{repository}/raw/{branch}/{path}"
141+
if (not is_public) or (not github_host.endswith("github.com")):
142+
# If the repository is private or hosted on a github enterprise instance,
143+
# then the real links to raw.githubusercontents.com will be short-lived.
144+
# In this case, it's better to keep an URL that will redirect to the correct URL just when asked.
145+
return f"{github_host}/{repository}/raw/{branch}/{path}"
145146

146147
# Otherwise, we can access the file directly. (shields.io doesn't like the
147148
# github.com domain)
@@ -154,7 +155,9 @@ def get_raw_file_url(
154155
# seconds.
155156

156157

157-
def get_repo_file_url(repository: str, branch: str, path: str = "/") -> str:
158+
def get_repo_file_url(
159+
github_host: str, repository: str, branch: str, path: str = "/"
160+
) -> str:
158161
"""
159162
Computes the GitHub Web UI URL for a given path:
160163
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:
166169
# See test_get_repo_file_url for precise specifications
167170
path = "/" + path.lstrip("/")
168171
part = "tree" if path.endswith("/") else "blob"
169-
return f"https://github.com/{repository}/{part}/{branch}{path}".rstrip("/")
172+
return f"{github_host}/{repository}/{part}/{branch}{path}".rstrip("/")
170173

171174

172-
def get_html_report_url(repository: str, branch: str) -> str:
175+
def get_html_report_url(
176+
github_host: str,
177+
repository: str,
178+
branch: str,
179+
use_gh_pages_html_url: bool = False,
180+
) -> str:
181+
"""
182+
Computes the URL for an HTML report:
183+
- If use_gh_pages_html_url is True:
184+
* GitHub.com => https://<user_or_org>.github.io/<repo>/<pages_path>
185+
* GitHub Enterprise => https://<host>/pages/<user_or_org>/<repo>/<pages_path>
186+
- If use_gh_pages_html_url is False:
187+
* GitHub.com => https://htmlpreview.github.io/?<readme_url>
188+
* GitHub Enterprise => <readme_url>
189+
"""
190+
html_report_path = "htmlcov/index.html"
173191
readme_url = get_repo_file_url(
174-
repository=repository, branch=branch, path="/htmlcov/index.html"
192+
github_host, repository=repository, branch=branch, path=html_report_path
175193
)
176-
return f"https://htmlpreview.github.io/?{readme_url}"
194+
195+
if github_host.endswith("github.com"):
196+
if use_gh_pages_html_url:
197+
user, repo = repository.split("/", 1)
198+
return f"https://{user}.github.io/{repo}/{html_report_path}"
199+
else:
200+
return f"https://htmlpreview.github.io/?{readme_url}"
201+
else:
202+
# Assume GitHub Enterprise
203+
if use_gh_pages_html_url:
204+
return f"{github_host}/pages/{repository}/{html_report_path}"
205+
206+
# Always fallback to the raw readme_url
207+
return readme_url

coverage_comment/template.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def get_comment_markdown(
130130
count_files: int,
131131
minimum_green: decimal.Decimal,
132132
minimum_orange: decimal.Decimal,
133+
github_host: str,
133134
repo_name: str,
134135
pr_number: int,
135136
base_template: str,
@@ -148,7 +149,7 @@ def get_comment_markdown(
148149
env.filters["pluralize"] = pluralize
149150
env.filters["compact"] = compact
150151
env.filters["file_url"] = functools.partial(
151-
get_file_url, repo_name=repo_name, pr_number=pr_number
152+
get_file_url, github_host=github_host, repo_name=repo_name, pr_number=pr_number
152153
)
153154
env.filters["get_badge_color"] = functools.partial(
154155
badge.get_badge_color,
@@ -304,11 +305,12 @@ def get_file_url(
304305
filename: pathlib.Path,
305306
lines: tuple[int, int] | None = None,
306307
*,
308+
github_host: str,
307309
repo_name: str,
308310
pr_number: int,
309311
) -> str:
310312
# 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
311-
s = f"https://github.com/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}"
313+
s = f"{github_host}/{repo_name}/pull/{pr_number}/files#diff-{hashlib.sha256(str(filename).encode('utf-8')).hexdigest()}"
312314

313315
if lines is not None:
314316
# R stands for Right side of the diff. But since we generate these links for new code we only need the right side.

dev-env

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function create-repo(){
4747
repo_dirname=$(basename ${GITHUB_REPOSITORY})
4848
mv "${repo_dirname}/"{*,.*} .
4949
rmdir "${repo_dirname}"
50-
git pull --ff-only origin master
50+
git pull --ff-only origin main
5151
}
5252

5353
function delete-repo(){
@@ -142,7 +142,7 @@ function help(){
142142
echo " coverage_comment" >&2
143143
echo " Launch the action locally (no argument)" >&2
144144
echo " pytest" >&2
145-
echo " Launch the the tests on the example repo (generates the coverage data that the action uses)" >&2
145+
echo " Launch the tests on the example repo (generates the coverage data that the action uses)" >&2
146146
echo "" >&2
147147

148148
echo "Change configuration:" >&2

tests/conftest.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import pathlib
99
import zipfile
10+
from collections.abc import Callable
1011

1112
import httpx
1213
import pytest
@@ -47,7 +48,7 @@ def _(**kwargs):
4748

4849

4950
@pytest.fixture
50-
def pull_request_config(base_config):
51+
def pull_request_config(base_config) -> Callable[..., settings.Config]:
5152
def _(**kwargs):
5253
defaults = {
5354
# GitHub stuff
@@ -250,6 +251,14 @@ def summary_file(tmp_path):
250251
return file
251252

252253

254+
@pytest.fixture
255+
def pull_request_event_payload(tmp_path):
256+
file = tmp_path / "event.json"
257+
file.touch()
258+
259+
return file
260+
261+
253262
_is_failed = []
254263

255264

0 commit comments

Comments
 (0)