Skip to content

Commit 45c85fa

Browse files
committed
refactor: clean up PR commit validation flow
1 parent 4bd8a4b commit 45c85fa

2 files changed

Lines changed: 351 additions & 338 deletions

File tree

main.py

Lines changed: 134 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
#!/usr/bin/env python3
22
import json
33
import os
4-
import sys
5-
import subprocess
64
import re
7-
from github import Github, Auth, GithubException # type: ignore
5+
import subprocess
6+
import sys
7+
from typing import TextIO
88

99
# Constants for message titles
1010
SUCCESS_TITLE = "# Commit-Check ✔️"
1111
FAILURE_TITLE = "# Commit-Check ❌"
12+
COMMIT_MESSAGE_DELIMITER = "\x00"
1213

1314
# Environment variables
1415
MESSAGE = os.getenv("MESSAGE", "false")
@@ -19,9 +20,20 @@
1920
JOB_SUMMARY = os.getenv("JOB_SUMMARY", "false")
2021
PR_COMMENTS = os.getenv("PR_COMMENTS", "false")
2122
GITHUB_STEP_SUMMARY = os.environ["GITHUB_STEP_SUMMARY"]
22-
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
23-
GITHUB_REPOSITORY = os.getenv("GITHUB_REPOSITORY")
24-
GITHUB_REF = os.getenv("GITHUB_REF")
23+
24+
25+
def env_flag(name: str, default: str = "false") -> bool:
26+
"""Read a GitHub Action boolean-style environment variable."""
27+
return os.getenv(name, default).lower() == "true"
28+
29+
30+
MESSAGE_ENABLED = env_flag("MESSAGE")
31+
BRANCH_ENABLED = env_flag("BRANCH")
32+
AUTHOR_NAME_ENABLED = env_flag("AUTHOR_NAME")
33+
AUTHOR_EMAIL_ENABLED = env_flag("AUTHOR_EMAIL")
34+
DRY_RUN_ENABLED = env_flag("DRY_RUN")
35+
JOB_SUMMARY_ENABLED = env_flag("JOB_SUMMARY")
36+
PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS")
2537

2638

2739
def log_env_vars():
@@ -35,27 +47,74 @@ def log_env_vars():
3547
print(f"PR_COMMENTS = {PR_COMMENTS}\n")
3648

3749

50+
def is_pr_event() -> bool:
51+
"""Return whether the workflow was triggered by a PR-style event."""
52+
return os.getenv("GITHUB_EVENT_NAME", "") in {"pull_request", "pull_request_target"}
53+
54+
55+
def parse_commit_messages(output: str) -> list[str]:
56+
"""Split git log output into individual commit messages."""
57+
return [
58+
message.rstrip("\n")
59+
for message in output.split(COMMIT_MESSAGE_DELIMITER)
60+
if message.rstrip("\n")
61+
]
62+
63+
64+
def get_messages_from_merge_ref() -> list[str]:
65+
"""Read PR commit messages from GitHub's synthetic merge commit."""
66+
result = subprocess.run(
67+
["git", "log", "--pretty=format:%B%x00", "--reverse", "HEAD^1..HEAD^2"],
68+
stdout=subprocess.PIPE,
69+
stderr=subprocess.PIPE,
70+
encoding="utf-8",
71+
check=False,
72+
)
73+
if result.returncode == 0 and result.stdout:
74+
return parse_commit_messages(result.stdout)
75+
return []
76+
77+
78+
def get_messages_from_head_ref(base_ref: str) -> list[str]:
79+
"""Read PR commit messages when the workflow checks out the head SHA."""
80+
result = subprocess.run(
81+
[
82+
"git",
83+
"log",
84+
"--pretty=format:%B%x00",
85+
"--reverse",
86+
f"origin/{base_ref}..HEAD",
87+
],
88+
stdout=subprocess.PIPE,
89+
stderr=subprocess.PIPE,
90+
encoding="utf-8",
91+
check=False,
92+
)
93+
if result.returncode == 0 and result.stdout:
94+
return parse_commit_messages(result.stdout)
95+
return []
96+
97+
3898
def get_pr_commit_messages() -> list[str]:
39-
"""Get all commit messages for the current PR (pull_request event only).
99+
"""Get all commit messages for the current PR workflow.
40100
41-
In a pull_request event, actions/checkout checks out a synthetic merge
101+
In pull_request-style workflows, actions/checkout checks out a synthetic merge
42102
commit (HEAD = merge of PR branch into base). HEAD^1 is the base branch
43103
tip, HEAD^2 is the PR branch tip. So HEAD^1..HEAD^2 gives all PR commits.
104+
If the workflow explicitly checks out the PR head SHA instead, fall back to
105+
diffing against origin/<base-ref> when that ref is available locally.
44106
"""
45-
if os.getenv("GITHUB_EVENT_NAME", "") != "pull_request":
107+
if not is_pr_event():
46108
return []
109+
47110
try:
48-
result = subprocess.run(
49-
["git", "log", "--pretty=format:%B%x00", "HEAD^1..HEAD^2"],
50-
stdout=subprocess.PIPE,
51-
stderr=subprocess.PIPE,
52-
encoding="utf-8",
53-
check=False,
54-
)
55-
if result.returncode == 0 and result.stdout:
56-
return [
57-
m.rstrip("\n") for m in result.stdout.split("\x00") if m.rstrip("\n")
58-
]
111+
messages = get_messages_from_merge_ref()
112+
if messages:
113+
return messages
114+
115+
base_ref = os.getenv("GITHUB_BASE_REF", "")
116+
if base_ref:
117+
return get_messages_from_head_ref(base_ref)
59118
except Exception as e:
60119
print(
61120
f"::warning::Failed to retrieve PR commit messages: {e}",
@@ -64,73 +123,72 @@ def get_pr_commit_messages() -> list[str]:
64123
return []
65124

66125

67-
def build_check_args(
68-
message: str, branch: str, author_name: str, author_email: str
69-
) -> list[str]:
70-
"""Maps 'true'/'false' flag values to CLI argument list."""
71-
flags = ["--message", "--branch", "--author-name", "--author-email"]
72-
values = [message, branch, author_name, author_email]
73-
return [flag for flag, value in zip(flags, values) if value == "true"]
126+
def run_check_command(
127+
args: list[str], result_file: TextIO, input_text: str | None = None
128+
) -> int:
129+
"""Run commit-check and write both stdout and stderr to the result file."""
130+
command = ["commit-check"] + args
131+
print(" ".join(command))
132+
result = subprocess.run(
133+
command,
134+
input=input_text,
135+
stdout=result_file,
136+
stderr=subprocess.STDOUT,
137+
text=True,
138+
check=False,
139+
)
140+
return result.returncode
74141

75142

76-
def run_pr_message_checks(pr_messages: list[str], result_file) -> int: # type: ignore[type-arg]
143+
def run_pr_message_checks(pr_messages: list[str], result_file: TextIO) -> int:
77144
"""Checks each PR commit message individually via commit-check --message.
78145
79146
Returns 1 if any message fails, 0 if all pass.
80147
"""
81148
has_failure = False
82-
for msg in pr_messages:
83-
result = subprocess.run(
84-
["commit-check", "--message"],
85-
input=msg,
86-
stdout=result_file,
87-
stderr=subprocess.PIPE,
88-
text=True,
89-
check=False,
90-
)
91-
has_failure = has_failure or (result.returncode != 0)
149+
total_messages = len(pr_messages)
150+
for index, msg in enumerate(pr_messages, start=1):
151+
subject = msg.splitlines()[0] if msg else "<empty commit message>"
152+
result_file.write(f"\n--- Commit {index}/{total_messages}: {subject}\n")
153+
has_failure = run_check_command(
154+
["--message"], result_file, input_text=msg
155+
) != 0 or has_failure
92156
return 1 if has_failure else 0
93157

94158

95-
def run_other_checks(args: list[str], result_file) -> int: # type: ignore[type-arg]
159+
def run_other_checks(args: list[str], result_file: TextIO) -> int:
96160
"""Runs non-message checks (branch, author) once. Returns 0 if args is empty."""
97161
if not args:
98162
return 0
99-
command = ["commit-check"] + args
100-
print(" ".join(command))
101-
result = subprocess.run(
102-
command, stdout=result_file, stderr=subprocess.PIPE, text=True, check=False
103-
)
104-
return result.returncode
163+
return run_check_command(args, result_file)
105164

106165

107-
def run_default_checks(args: list[str], result_file) -> int: # type: ignore[type-arg]
108-
"""Runs all checks at once (non-PR context or message disabled)."""
109-
command = ["commit-check"] + args
110-
print(" ".join(command))
111-
result = subprocess.run(
112-
command, stdout=result_file, stderr=subprocess.PIPE, text=True, check=False
113-
)
114-
return result.returncode
166+
def build_check_args() -> list[str]:
167+
"""Map enabled validation switches to commit-check CLI arguments."""
168+
flags = [
169+
("--message", MESSAGE_ENABLED),
170+
("--branch", BRANCH_ENABLED),
171+
("--author-name", AUTHOR_NAME_ENABLED),
172+
("--author-email", AUTHOR_EMAIL_ENABLED),
173+
]
174+
return [flag for flag, enabled in flags if enabled]
115175

116176

117177
def run_commit_check() -> int:
118178
"""Runs the commit-check command and logs the result."""
119-
args = build_check_args(MESSAGE, BRANCH, AUTHOR_NAME, AUTHOR_EMAIL)
120-
total_rc = 0
179+
args = build_check_args()
121180
with open("result.txt", "w") as result_file:
122-
if MESSAGE == "true":
181+
if MESSAGE_ENABLED:
123182
pr_messages = get_pr_commit_messages()
124183
if pr_messages:
125184
# In PR context: check each commit message individually to avoid
126185
# only validating the synthetic merge commit at HEAD.
127-
total_rc += run_pr_message_checks(pr_messages, result_file)
186+
message_rc = run_pr_message_checks(pr_messages, result_file)
128187
other_args = [a for a in args if a != "--message"]
129-
total_rc += run_other_checks(other_args, result_file)
130-
return total_rc
188+
other_rc = run_other_checks(other_args, result_file)
189+
return 1 if message_rc or other_rc else 0
131190
# Non-PR context or message disabled: run all checks at once
132-
total_rc += run_default_checks(args, result_file)
133-
return total_rc
191+
return 1 if run_check_command(args, result_file) else 0
134192

135193

136194
def read_result_file() -> str | None:
@@ -144,21 +202,22 @@ def read_result_file() -> str | None:
144202
return None
145203

146204

205+
def build_result_body(result_text: str | None) -> str:
206+
"""Create the human-readable result body used in summaries and PR comments."""
207+
if result_text is None:
208+
return SUCCESS_TITLE
209+
return f"{FAILURE_TITLE}\n```\n{result_text}\n```"
210+
211+
147212
def add_job_summary() -> int:
148213
"""Adds the commit check result to the GitHub job summary."""
149-
if JOB_SUMMARY == "false":
214+
if not JOB_SUMMARY_ENABLED:
150215
return 0
151216

152217
result_text = read_result_file()
153218

154-
summary_content = (
155-
SUCCESS_TITLE
156-
if result_text is None
157-
else f"{FAILURE_TITLE}\n```\n{result_text}\n```"
158-
)
159-
160219
with open(GITHUB_STEP_SUMMARY, "a") as summary_file:
161-
summary_file.write(summary_content)
220+
summary_file.write(build_result_body(result_text))
162221

163222
return 0 if result_text is None else 1
164223

@@ -183,7 +242,7 @@ def is_fork_pr() -> bool:
183242

184243
def add_pr_comments() -> int:
185244
"""Posts the commit check result as a comment on the pull request."""
186-
if PR_COMMENTS == "false":
245+
if not PR_COMMENTS_ENABLED:
187246
return 0
188247

189248
# Fork PRs triggered by the pull_request event receive a read-only token;
@@ -199,6 +258,8 @@ def add_pr_comments() -> int:
199258
return 0
200259

201260
try:
261+
from github import Auth, Github, GithubException # type: ignore
262+
202263
token = os.getenv("GITHUB_TOKEN")
203264
repo_name = os.getenv("GITHUB_REPOSITORY")
204265
pr_number = os.getenv("GITHUB_REF")
@@ -214,15 +275,9 @@ def add_pr_comments() -> int:
214275
repo = g.get_repo(repo_name)
215276
pull_request = repo.get_issue(int(pr_number))
216277

217-
# Prepare comment content
218278
result_text = read_result_file()
219-
pr_comment_body = (
220-
SUCCESS_TITLE
221-
if result_text is None
222-
else f"{FAILURE_TITLE}\n```\n{result_text}\n```"
223-
)
279+
pr_comment_body = build_result_body(result_text)
224280

225-
# Fetch all existing comments on the PR
226281
comments = pull_request.get_comments()
227282
matching_comments = [
228283
c
@@ -282,12 +337,9 @@ def main():
282337
"""Main function to run commit-check, add job summary and post PR comments."""
283338
log_env_vars()
284339

285-
# Combine return codes
286-
ret_code = run_commit_check()
287-
ret_code += add_job_summary()
288-
ret_code += add_pr_comments()
340+
ret_code = max(run_commit_check(), add_job_summary(), add_pr_comments())
289341

290-
if DRY_RUN == "true":
342+
if DRY_RUN_ENABLED:
291343
ret_code = 0
292344

293345
result_text = read_result_file()

0 commit comments

Comments
 (0)