Skip to content

Commit 211af00

Browse files
authored
Merge pull request #248 from py-cov-action/action-on-push
2 parents 5611aa6 + 66ad832 commit 211af00

File tree

13 files changed

+525
-179
lines changed

13 files changed

+525
-179
lines changed

coverage_comment/activity.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
This module is responsible for identifying what the action should be doing
3+
based on the github event type and repository.
4+
5+
The code in main should be as straightforward as possible, we're offloading
6+
the branching logic to this module.
7+
"""
8+
9+
10+
class ActivityNotFound(Exception):
11+
pass
12+
13+
14+
def find_activity(
15+
event_name: str,
16+
is_default_branch: bool,
17+
) -> str:
18+
"""Find the activity to perform based on the event type and payload."""
19+
if event_name == "workflow_run":
20+
return "post_comment"
21+
22+
if event_name == "push" and is_default_branch:
23+
return "save_coverage_data_files"
24+
25+
if event_name not in {"pull_request", "push"}:
26+
raise ActivityNotFound
27+
28+
return "process_pr"

coverage_comment/github.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import dataclasses
2-
import functools
32
import io
43
import json
54
import pathlib
@@ -73,47 +72,53 @@ def download_artifact(
7372
raise NoArtifact(f"File named {filename} not found in artifact {artifact_name}")
7473

7574

76-
def get_pr_number_from_workflow_run(
75+
def get_branch_from_workflow_run(
7776
github: github_client.GitHub, repository: str, run_id: int
78-
) -> int:
79-
# It's quite horrendous to access the PR number from a workflow run,
80-
# especially when it's not the "pull_request" workflow run itself but a
81-
# "workflow_run" workflow run that runs after the "pull_request" workflow
82-
# run.
83-
#
84-
# 1. We need the user to give us access to the "pull_request" workflow run
85-
# id. That's why we request to be sent the following as input:
86-
# GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }}
87-
# 2. From that run, we get the corresponding branch, and the owner of the branch
88-
# 3. We list open PRs that have that branch as head branch. There should be only
89-
# one.
90-
# 4. If there's no open PRs, we look at all PRs. We take the most recently
91-
# updated one
92-
77+
) -> tuple[str, str]:
9378
repo_path = github.repos(repository)
9479
run = repo_path.actions.runs(run_id).get()
9580
branch = run.head_branch
96-
repo_name = run.head_repository.full_name
97-
full_branch = f"{repo_name}:{branch}"
98-
get_prs = functools.partial(
99-
repo_path.pulls.get,
100-
head=full_branch,
101-
sort="updated",
102-
direction="desc",
103-
)
81+
owner = run.head_repository.owner.login
82+
return owner, branch
83+
84+
85+
def find_pr_for_branch(
86+
github: github_client.GitHub, repository: str, owner: str, branch: str
87+
) -> int:
88+
# The full branch is in the form of "owner:branch" as specified in
89+
# https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests
90+
# but it seems to also work with "owner/repo:branch"
91+
92+
full_branch = f"{owner}:{branch}"
93+
94+
common_kwargs = {"head": full_branch, "sort": "updated", "direction": "desc"}
10495
try:
105-
return next(iter(pr.number for pr in get_prs(state="open")))
96+
return next(
97+
iter(
98+
pr.number
99+
for pr in github.repos(repository).pulls.get(
100+
state="open", **common_kwargs
101+
)
102+
)
103+
)
106104
except StopIteration:
107105
pass
108-
log.info(f"No open PR found for branch {full_branch}, defaulting to all PRs")
106+
log.info(f"No open PR found for branch {branch}, defaulting to all PRs")
109107

110108
try:
111-
return next(iter(pr.number for pr in get_prs(state="all")))
109+
return next(
110+
iter(
111+
pr.number
112+
for pr in github.repos(repository).pulls.get(
113+
state="all", **common_kwargs
114+
)
115+
)
116+
)
112117
except StopIteration:
113-
raise CannotDeterminePR(f"No open PR found for branch {full_branch}")
118+
raise CannotDeterminePR(f"No open PR found for branch {branch}")
114119

115120

116-
def get_my_login(github: github_client.GitHub):
121+
def get_my_login(github: github_client.GitHub) -> str:
117122
try:
118123
response = github.user.get()
119124
except github_client.Forbidden:

coverage_comment/main.py

Lines changed: 94 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import httpx
77

8+
from coverage_comment import activity as activity_module
89
from coverage_comment import annotations, comment_file, communication
910
from coverage_comment import coverage as coverage_module
1011
from coverage_comment import (
@@ -60,69 +61,79 @@ def action(
6061
git: subprocess.Git,
6162
) -> int:
6263
log.debug(f"Operating on {config.GITHUB_REF}")
63-
64+
gh = github_client.GitHub(session=github_session)
6465
event_name = config.GITHUB_EVENT_NAME
65-
if event_name not in {"pull_request", "push", "workflow_run"}:
66+
repo_info = github.get_repository_info(
67+
github=gh, repository=config.GITHUB_REPOSITORY
68+
)
69+
try:
70+
activity = activity_module.find_activity(
71+
event_name=event_name,
72+
is_default_branch=repo_info.is_default_branch(ref=config.GITHUB_REF),
73+
)
74+
except activity_module.ActivityNotFound:
6675
log.error(
67-
'This action has only been designed to work for "pull_request", "branch" '
76+
'This action has only been designed to work for "pull_request", "push" '
6877
f'or "workflow_run" actions, not "{event_name}". Because there are security '
6978
"implications. If you have a different usecase, please open an issue, "
7079
"we'll be glad to add compatibility."
7180
)
7281
return 1
7382

74-
if event_name in {"pull_request", "push"}:
75-
raw_coverage, coverage = coverage_module.get_coverage_info(
76-
merge=config.MERGE_COVERAGE_FILES, coverage_path=config.COVERAGE_PATH
83+
if activity == "save_coverage_data_files":
84+
return save_coverage_data_files(
85+
config=config,
86+
git=git,
87+
http_session=http_session,
88+
repo_info=repo_info,
89+
)
90+
91+
elif activity == "process_pr":
92+
return process_pr(
93+
config=config,
94+
gh=gh,
95+
repo_info=repo_info,
7796
)
78-
if event_name == "pull_request":
79-
diff_coverage = coverage_module.get_diff_coverage_info(
80-
base_ref=config.GITHUB_BASE_REF, coverage_path=config.COVERAGE_PATH
81-
)
82-
if config.ANNOTATE_MISSING_LINES:
83-
annotations.create_pr_annotations(
84-
annotation_type=config.ANNOTATION_TYPE, diff_coverage=diff_coverage
85-
)
86-
return generate_comment(
87-
config=config,
88-
coverage=coverage,
89-
diff_coverage=diff_coverage,
90-
github_session=github_session,
91-
)
92-
else:
93-
# event_name == "push"
94-
return save_coverage_data_files(
95-
config=config,
96-
coverage=coverage,
97-
raw_coverage_data=raw_coverage,
98-
github_session=github_session,
99-
git=git,
100-
http_session=http_session,
101-
)
10297

10398
else:
104-
# event_name == "workflow_run"
99+
# activity == "post_comment":
105100
return post_comment(
106101
config=config,
107-
github_session=github_session,
102+
gh=gh,
108103
)
109104

110105

111-
def generate_comment(
106+
def process_pr(
112107
config: settings.Config,
113-
coverage: coverage_module.Coverage,
114-
diff_coverage: coverage_module.DiffCoverage,
115-
github_session: httpx.Client,
108+
gh: github_client.GitHub,
109+
repo_info: github.RepositoryInfo,
116110
) -> int:
117111
log.info("Generating comment for PR")
118112

119-
gh = github_client.GitHub(session=github_session)
120-
121-
previous_coverage_data_file = storage.get_datafile_contents(
122-
github=gh,
123-
repository=config.GITHUB_REPOSITORY,
124-
branch=config.COVERAGE_DATA_BRANCH,
113+
_, coverage = coverage_module.get_coverage_info(
114+
merge=config.MERGE_COVERAGE_FILES,
115+
coverage_path=config.COVERAGE_PATH,
125116
)
117+
base_ref = config.GITHUB_BASE_REF or repo_info.default_branch
118+
diff_coverage = coverage_module.get_diff_coverage_info(
119+
base_ref=base_ref, coverage_path=config.COVERAGE_PATH
120+
)
121+
122+
# It only really makes sense to display a comparison with the previous
123+
# coverage if the PR target is the branch in which the coverage data is
124+
# stored, e.g. the default branch.
125+
# In the case we're running on a branch without a PR yet, we can't know
126+
# if it's going to target the default branch, so we display it.
127+
previous_coverage_data_file = None
128+
pr_targets_default_branch = base_ref == repo_info.default_branch
129+
130+
if pr_targets_default_branch:
131+
previous_coverage_data_file = storage.get_datafile_contents(
132+
github=gh,
133+
repository=config.GITHUB_REPOSITORY,
134+
branch=config.COVERAGE_DATA_BRANCH,
135+
)
136+
126137
previous_coverage = None
127138
if previous_coverage_data_file:
128139
previous_coverage = files.parse_datafile(contents=previous_coverage_data_file)
@@ -134,6 +145,7 @@ def generate_comment(
134145
previous_coverage_rate=previous_coverage,
135146
base_template=template.read_template_file("comment.md.j2"),
136147
custom_template=config.COMMENT_TEMPLATE,
148+
pr_targets_default_branch=pr_targets_default_branch,
137149
)
138150
except template.MissingMarker:
139151
log.error(
@@ -152,21 +164,39 @@ def generate_comment(
152164
)
153165
return 1
154166

155-
assert config.GITHUB_PR_NUMBER
156-
157167
github.add_job_summary(
158168
content=comment, github_step_summary=config.GITHUB_STEP_SUMMARY
159169
)
170+
pr_number: int | None = config.GITHUB_PR_NUMBER
171+
if pr_number is None:
172+
# If we don't have a PR number, we're launched from a push event,
173+
# so we need to find the PR number from the branch name
174+
assert config.GITHUB_BRANCH_NAME
175+
try:
176+
pr_number = github.find_pr_for_branch(
177+
github=gh,
178+
# A push event cannot be initiated from a forked repository
179+
repository=config.GITHUB_REPOSITORY,
180+
owner=config.GITHUB_REPOSITORY.split("/")[0],
181+
branch=config.GITHUB_BRANCH_NAME,
182+
)
183+
except github.CannotDeterminePR:
184+
pr_number = None
185+
186+
if pr_number is not None and config.ANNOTATE_MISSING_LINES:
187+
annotations.create_pr_annotations(
188+
annotation_type=config.ANNOTATION_TYPE, diff_coverage=diff_coverage
189+
)
160190

161191
try:
162-
if config.FORCE_WORKFLOW_RUN:
192+
if config.FORCE_WORKFLOW_RUN or not pr_number:
163193
raise github.CannotPostComment
164194

165195
github.post_comment(
166196
github=gh,
167197
me=github.get_my_login(github=gh),
168198
repository=config.GITHUB_REPOSITORY,
169-
pr_number=config.GITHUB_PR_NUMBER,
199+
pr_number=pr_number,
170200
contents=comment,
171201
marker=template.MARKER,
172202
)
@@ -193,21 +223,29 @@ def generate_comment(
193223
return 0
194224

195225

196-
def post_comment(config: settings.Config, github_session: httpx.Client) -> int:
226+
def post_comment(
227+
config: settings.Config,
228+
gh: github_client.GitHub,
229+
) -> int:
197230
log.info("Posting comment to PR")
198231

199232
if not config.GITHUB_PR_RUN_ID:
200233
log.error("Missing input GITHUB_PR_RUN_ID. Please consult the documentation.")
201234
return 1
202235

203-
gh = github_client.GitHub(session=github_session)
204236
me = github.get_my_login(github=gh)
205237
log.info(f"Search for PR associated with run id {config.GITHUB_PR_RUN_ID}")
238+
owner, branch = github.get_branch_from_workflow_run(
239+
github=gh,
240+
run_id=config.GITHUB_PR_RUN_ID,
241+
repository=config.GITHUB_REPOSITORY,
242+
)
206243
try:
207-
pr_number = github.get_pr_number_from_workflow_run(
244+
pr_number = github.find_pr_for_branch(
208245
github=gh,
209-
run_id=config.GITHUB_PR_RUN_ID,
210246
repository=config.GITHUB_REPOSITORY,
247+
owner=owner,
248+
branch=branch,
211249
)
212250
except github.CannotDeterminePR:
213251
log.error(
@@ -250,25 +288,17 @@ def post_comment(config: settings.Config, github_session: httpx.Client) -> int:
250288

251289
def save_coverage_data_files(
252290
config: settings.Config,
253-
coverage: coverage_module.Coverage,
254-
raw_coverage_data: dict,
255-
github_session: httpx.Client,
256291
git: subprocess.Git,
257292
http_session: httpx.Client,
293+
repo_info: github.RepositoryInfo,
258294
) -> int:
259-
gh = github_client.GitHub(session=github_session)
260-
repo_info = github.get_repository_info(
261-
github=gh,
262-
repository=config.GITHUB_REPOSITORY,
263-
)
264-
is_default_branch = repo_info.is_default_branch(ref=config.GITHUB_REF)
265-
log.debug(f"On default branch: {is_default_branch}")
295+
log.info("Computing coverage files & badge")
266296

267-
if not is_default_branch:
268-
log.info("Skipping badge save as we're not on the default branch")
269-
return 0
297+
raw_coverage_data, coverage = coverage_module.get_coverage_info(
298+
merge=config.MERGE_COVERAGE_FILES,
299+
coverage_path=config.COVERAGE_PATH,
300+
)
270301

271-
log.info("Computing coverage files & badge")
272302
operations: list[files.Operation] = files.compute_files(
273303
line_rate=coverage.info.percent_covered,
274304
raw_coverage_data=raw_coverage_data,

coverage_comment/settings.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,15 @@ def str_to_bool(value: str) -> bool:
3333
class Config:
3434
"""This object defines the environment variables"""
3535

36+
# A branch name, not a fully-formed ref. For example, `main`.
3637
GITHUB_BASE_REF: str
3738
GITHUB_TOKEN: str = dataclasses.field(repr=False)
3839
GITHUB_REPOSITORY: str
40+
# > The ref given is fully-formed, meaning that for branches the format is
41+
# > `refs/heads/<branch_name>`, for pull requests it is
42+
# > `refs/pull/<pr_number>/merge`, and for tags it is `refs/tags/<tag_name>`.
43+
# > For example, `refs/heads/feature-branch-1`.
44+
# (from https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables )
3945
GITHUB_REF: str
4046
GITHUB_EVENT_NAME: str
4147
GITHUB_PR_RUN_ID: int | None
@@ -119,6 +125,13 @@ def GITHUB_PR_NUMBER(self) -> int | None:
119125
return int(self.GITHUB_REF.split("/")[2])
120126
return None
121127

128+
@property
129+
def GITHUB_BRANCH_NAME(self) -> str | None:
130+
# "refs/head/my_branch_name"
131+
if "heads" in self.GITHUB_REF:
132+
return self.GITHUB_REF.split("/", 2)[2]
133+
return None
134+
122135
# We need to type environ as a MutableMapping because that's what
123136
# os.environ is, and just saying `dict[str, str]` is not enough to make
124137
# mypy happy

0 commit comments

Comments
 (0)