Skip to content

Commit 7c158d4

Browse files
shenxianpengCopilotpre-commit-ci[bot]
authored
fix: implement PR commit message retrieval and validation (#188)
* feat: implement PR commit message retrieval and validation in commit-check * fix: correct variable names in subprocess calls for clarity * fix: get original commit message content in get_pr_commit_messages() (#190) * chore: Update commit-check version to 2.4.3 * feat: Enable autofix for pull requests in pre-commit config * feat: refactor commit-check logic and add unit tests for new functionality * fix: format list comprehensions for better readability in get_pr_commit_messages() and related tests * fix: add args to codespell hook to ignore specific words * fix: update main_test.py * fix: improve error handling and return codes in commit message checks * fix: auto fixes from pre-commit.com hooks * refactor: clean up PR commit validation flow * chore: auto fixes from pre-commit.com hooks * refactor: clean up PR commit validation flow * chore: auto fixes from pre-commit.com hooks * feat: enhance commit message checks with section separators and no-banner option * fix: update commit message formatting to improve output clarity and consistency * chore: auto fixes from pre-commit.com hooks * fix: simplify output handling in PR message checks and update test assertions * fix: update output formatting in PR message checks to include commit indexing --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d9176bb commit 7c158d4

2 files changed

Lines changed: 779 additions & 50 deletions

File tree

main.py

Lines changed: 192 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
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"
13+
COMMIT_SECTION_SEPARATOR = "\n---\n"
1214

1315
# Environment variables
1416
MESSAGE = os.getenv("MESSAGE", "false")
@@ -19,9 +21,20 @@
1921
JOB_SUMMARY = os.getenv("JOB_SUMMARY", "false")
2022
PR_COMMENTS = os.getenv("PR_COMMENTS", "false")
2123
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")
24+
25+
26+
def env_flag(name: str, default: str = "false") -> bool:
27+
"""Read a GitHub Action boolean-style environment variable."""
28+
return os.getenv(name, default).lower() == "true"
29+
30+
31+
MESSAGE_ENABLED = env_flag("MESSAGE")
32+
BRANCH_ENABLED = env_flag("BRANCH")
33+
AUTHOR_NAME_ENABLED = env_flag("AUTHOR_NAME")
34+
AUTHOR_EMAIL_ENABLED = env_flag("AUTHOR_EMAIL")
35+
DRY_RUN_ENABLED = env_flag("DRY_RUN")
36+
JOB_SUMMARY_ENABLED = env_flag("JOB_SUMMARY")
37+
PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS")
2538

2639

2740
def log_env_vars():
@@ -35,35 +48,170 @@ def log_env_vars():
3548
print(f"PR_COMMENTS = {PR_COMMENTS}\n")
3649

3750

38-
def run_commit_check() -> int:
39-
"""Runs the commit-check command and logs the result."""
40-
args = [
41-
"--message",
42-
"--branch",
43-
"--author-name",
44-
"--author-email",
51+
def is_pr_event() -> bool:
52+
"""Return whether the workflow was triggered by a PR-style event."""
53+
return os.getenv("GITHUB_EVENT_NAME", "") in {"pull_request", "pull_request_target"}
54+
55+
56+
def parse_commit_messages(output: str) -> list[str]:
57+
"""Split git log output into individual commit messages."""
58+
return [
59+
message.strip("\n")
60+
for message in output.split(COMMIT_MESSAGE_DELIMITER)
61+
if message.strip("\n")
4562
]
46-
args = [
47-
arg
48-
for arg, value in zip(
49-
args,
50-
[
51-
MESSAGE,
52-
BRANCH,
53-
AUTHOR_NAME,
54-
AUTHOR_EMAIL,
55-
],
63+
64+
65+
def get_messages_from_merge_ref() -> list[str]:
66+
"""Read PR commit messages from GitHub's synthetic merge commit."""
67+
result = subprocess.run(
68+
["git", "log", "--pretty=format:%B%x00", "--reverse", "HEAD^1..HEAD^2"],
69+
stdout=subprocess.PIPE,
70+
stderr=subprocess.PIPE,
71+
encoding="utf-8",
72+
check=False,
73+
)
74+
if result.returncode == 0 and result.stdout:
75+
return parse_commit_messages(result.stdout)
76+
return []
77+
78+
79+
def get_messages_from_head_ref(base_ref: str) -> list[str]:
80+
"""Read PR commit messages when the workflow checks out the head SHA."""
81+
result = subprocess.run(
82+
[
83+
"git",
84+
"log",
85+
"--pretty=format:%B%x00",
86+
"--reverse",
87+
f"origin/{base_ref}..HEAD",
88+
],
89+
stdout=subprocess.PIPE,
90+
stderr=subprocess.PIPE,
91+
encoding="utf-8",
92+
check=False,
93+
)
94+
if result.returncode == 0 and result.stdout:
95+
return parse_commit_messages(result.stdout)
96+
return []
97+
98+
99+
def get_pr_commit_messages() -> list[str]:
100+
"""Get all commit messages for the current PR workflow.
101+
102+
In pull_request-style workflows, actions/checkout checks out a synthetic merge
103+
commit (HEAD = merge of PR branch into base). HEAD^1 is the base branch
104+
tip, HEAD^2 is the PR branch tip. So HEAD^1..HEAD^2 gives all PR commits.
105+
If the workflow explicitly checks out the PR head SHA instead, fall back to
106+
diffing against origin/<base-ref> when that ref is available locally.
107+
"""
108+
if not is_pr_event():
109+
return []
110+
111+
try:
112+
messages = get_messages_from_merge_ref()
113+
if messages:
114+
return messages
115+
116+
base_ref = os.getenv("GITHUB_BASE_REF", "")
117+
if base_ref:
118+
return get_messages_from_head_ref(base_ref)
119+
except Exception as e:
120+
print(
121+
f"::warning::Failed to retrieve PR commit messages: {e}",
122+
file=sys.stderr,
56123
)
57-
if value == "true"
58-
]
124+
return []
59125

126+
127+
def run_check_command(
128+
args: list[str],
129+
result_file: TextIO,
130+
input_text: str | None = None,
131+
output_prefix: str | None = None,
132+
) -> int:
133+
"""Run commit-check and write both stdout and stderr to the result file."""
60134
command = ["commit-check"] + args
61135
print(" ".join(command))
62-
with open("result.txt", "w") as result_file:
63-
result = subprocess.run(
64-
command, stdout=result_file, stderr=subprocess.PIPE, check=False
136+
result = subprocess.run(
137+
command,
138+
input=input_text,
139+
stdout=subprocess.PIPE,
140+
stderr=subprocess.STDOUT,
141+
text=True,
142+
check=False,
143+
)
144+
if result.stdout:
145+
if output_prefix:
146+
result_file.write(output_prefix)
147+
result_file.write(result.stdout.rstrip("\n"))
148+
result_file.write("\n")
149+
return result.returncode
150+
151+
152+
def run_pr_message_checks(pr_messages: list[str], result_file: TextIO) -> int:
153+
"""Checks each PR commit message individually via commit-check --message.
154+
155+
Returns 1 if any message fails, 0 if all pass.
156+
"""
157+
has_failure = False
158+
emitted_failure_output = False
159+
total = len(pr_messages)
160+
for index, msg in enumerate(pr_messages, start=1):
161+
command_args = ["--message"]
162+
if emitted_failure_output:
163+
command_args.append("--no-banner")
164+
165+
if emitted_failure_output:
166+
output_prefix = f"\n--- Commit {index}/{total}:\n"
167+
else:
168+
output_prefix = None
169+
170+
return_code = run_check_command(
171+
command_args,
172+
result_file,
173+
input_text=msg,
174+
output_prefix=output_prefix,
65175
)
66-
return result.returncode
176+
if return_code != 0:
177+
has_failure = True
178+
emitted_failure_output = True
179+
return 1 if has_failure else 0
180+
181+
182+
def run_other_checks(args: list[str], result_file: TextIO) -> int:
183+
"""Runs non-message checks (branch, author) once. Returns 0 if args is empty."""
184+
if not args:
185+
return 0
186+
return run_check_command(args, result_file)
187+
188+
189+
def build_check_args() -> list[str]:
190+
"""Map enabled validation switches to commit-check CLI arguments."""
191+
flags = [
192+
("--message", MESSAGE_ENABLED),
193+
("--branch", BRANCH_ENABLED),
194+
("--author-name", AUTHOR_NAME_ENABLED),
195+
("--author-email", AUTHOR_EMAIL_ENABLED),
196+
]
197+
return [flag for flag, enabled in flags if enabled]
198+
199+
200+
def run_commit_check() -> int:
201+
"""Runs the commit-check command and logs the result."""
202+
args = build_check_args()
203+
with open("result.txt", "w") as result_file:
204+
if MESSAGE_ENABLED:
205+
pr_messages = get_pr_commit_messages()
206+
if pr_messages:
207+
# In PR context: check each commit message individually to avoid
208+
# only validating the synthetic merge commit at HEAD.
209+
message_rc = run_pr_message_checks(pr_messages, result_file)
210+
other_args = [a for a in args if a != "--message"]
211+
other_rc = run_other_checks(other_args, result_file)
212+
return 1 if message_rc or other_rc else 0
213+
# Non-PR context or message disabled: run all checks at once
214+
return 1 if run_check_command(args, result_file) else 0
67215

68216

69217
def read_result_file() -> str | None:
@@ -77,21 +225,22 @@ def read_result_file() -> str | None:
77225
return None
78226

79227

228+
def build_result_body(result_text: str | None) -> str:
229+
"""Create the human-readable result body used in summaries and PR comments."""
230+
if result_text is None:
231+
return SUCCESS_TITLE
232+
return f"{FAILURE_TITLE}\n```\n{result_text}\n```"
233+
234+
80235
def add_job_summary() -> int:
81236
"""Adds the commit check result to the GitHub job summary."""
82-
if JOB_SUMMARY == "false":
237+
if not JOB_SUMMARY_ENABLED:
83238
return 0
84239

85240
result_text = read_result_file()
86241

87-
summary_content = (
88-
SUCCESS_TITLE
89-
if result_text is None
90-
else f"{FAILURE_TITLE}\n```\n{result_text}\n```"
91-
)
92-
93242
with open(GITHUB_STEP_SUMMARY, "a") as summary_file:
94-
summary_file.write(summary_content)
243+
summary_file.write(build_result_body(result_text))
95244

96245
return 0 if result_text is None else 1
97246

@@ -116,7 +265,7 @@ def is_fork_pr() -> bool:
116265

117266
def add_pr_comments() -> int:
118267
"""Posts the commit check result as a comment on the pull request."""
119-
if PR_COMMENTS == "false":
268+
if not PR_COMMENTS_ENABLED:
120269
return 0
121270

122271
# Fork PRs triggered by the pull_request event receive a read-only token;
@@ -132,6 +281,8 @@ def add_pr_comments() -> int:
132281
return 0
133282

134283
try:
284+
from github import Auth, Github, GithubException # type: ignore
285+
135286
token = os.getenv("GITHUB_TOKEN")
136287
repo_name = os.getenv("GITHUB_REPOSITORY")
137288
pr_number = os.getenv("GITHUB_REF")
@@ -147,15 +298,9 @@ def add_pr_comments() -> int:
147298
repo = g.get_repo(repo_name)
148299
pull_request = repo.get_issue(int(pr_number))
149300

150-
# Prepare comment content
151301
result_text = read_result_file()
152-
pr_comment_body = (
153-
SUCCESS_TITLE
154-
if result_text is None
155-
else f"{FAILURE_TITLE}\n```\n{result_text}\n```"
156-
)
302+
pr_comment_body = build_result_body(result_text)
157303

158-
# Fetch all existing comments on the PR
159304
comments = pull_request.get_comments()
160305
matching_comments = [
161306
c
@@ -215,12 +360,9 @@ def main():
215360
"""Main function to run commit-check, add job summary and post PR comments."""
216361
log_env_vars()
217362

218-
# Combine return codes
219-
ret_code = run_commit_check()
220-
ret_code += add_job_summary()
221-
ret_code += add_pr_comments()
363+
ret_code = max(run_commit_check(), add_job_summary(), add_pr_comments())
222364

223-
if DRY_RUN == "true":
365+
if DRY_RUN_ENABLED:
224366
ret_code = 0
225367

226368
result_text = read_result_file()

0 commit comments

Comments
 (0)