11#!/usr/bin/env python3
22import json
33import os
4- import sys
5- import subprocess
64import 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
1010SUCCESS_TITLE = "# Commit-Check ✔️"
1111FAILURE_TITLE = "# Commit-Check ❌"
12+ COMMIT_MESSAGE_DELIMITER = "\x00 "
1213
1314# Environment variables
1415MESSAGE = os .getenv ("MESSAGE" , "false" )
1920JOB_SUMMARY = os .getenv ("JOB_SUMMARY" , "false" )
2021PR_COMMENTS = os .getenv ("PR_COMMENTS" , "false" )
2122GITHUB_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
2739def 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+
3898def 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
117177def 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
136194def 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+
147212def 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
184243def 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