Skip to content

Commit 3222c3c

Browse files
committed
feat: add pr-title input to validate pull request title
Add a new pr-title input that validates the PR title against the Conventional Commits specification using commit-check's --message check. This is especially useful for teams using Squash and Merge, where the PR title becomes the final commit message. Key changes: - action.yml: add pr-title input (default: false) - main.py: add get_pr_title(), PR_TITLE_ENABLED flag, integrate PR title validation into run_commit_check() - main_test.py: add tests for get_pr_title(), PR title in run_commit_check(), and initial_emitted parameter Closes #93
1 parent eec6c29 commit 3222c3c

3 files changed

Lines changed: 245 additions & 11 deletions

File tree

‎action.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ inputs:
3333
description: post results to the pull request comments
3434
required: false
3535
default: false
36+
pr-title:
37+
description: check pull request title following conventional commits
38+
required: false
39+
default: false
3640
runs:
3741
using: "composite"
3842
steps:
@@ -69,4 +73,5 @@ runs:
6973
DRY_RUN: ${{ inputs.dry-run }}
7074
JOB_SUMMARY: ${{ inputs.job-summary }}
7175
PR_COMMENTS: ${{ inputs.pr-comments }}
76+
PR_TITLE: ${{ inputs.pr-title }}
7277
GITHUB_TOKEN: ${{ github.token }}

‎main.py‎

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
DRY_RUN = os.getenv("DRY_RUN", "false")
2121
JOB_SUMMARY = os.getenv("JOB_SUMMARY", "false")
2222
PR_COMMENTS = os.getenv("PR_COMMENTS", "false")
23+
PR_TITLE = os.getenv("PR_TITLE", "false")
2324
GITHUB_STEP_SUMMARY = os.environ["GITHUB_STEP_SUMMARY"]
2425

2526

@@ -35,6 +36,7 @@ def env_flag(name: str, default: str = "false") -> bool:
3536
DRY_RUN_ENABLED = env_flag("DRY_RUN")
3637
JOB_SUMMARY_ENABLED = env_flag("JOB_SUMMARY")
3738
PR_COMMENTS_ENABLED = env_flag("PR_COMMENTS")
39+
PR_TITLE_ENABLED = env_flag("PR_TITLE")
3840

3941

4042
def log_env_vars():
@@ -45,14 +47,33 @@ def log_env_vars():
4547
print(f"AUTHOR_EMAIL = {AUTHOR_EMAIL}")
4648
print(f"DRY_RUN = {DRY_RUN}")
4749
print(f"JOB_SUMMARY = {JOB_SUMMARY}")
48-
print(f"PR_COMMENTS = {PR_COMMENTS}\n")
50+
print(f"PR_COMMENTS = {PR_COMMENTS}")
51+
print(f"PR_TITLE = {PR_TITLE}\n")
4952

5053

5154
def is_pr_event() -> bool:
5255
"""Return whether the workflow was triggered by a PR-style event."""
5356
return os.getenv("GITHUB_EVENT_NAME", "") in {"pull_request", "pull_request_target"}
5457

5558

59+
def get_pr_title() -> str | None:
60+
"""Read PR title from GitHub event payload."""
61+
if not is_pr_event():
62+
return None
63+
event_path = os.getenv("GITHUB_EVENT_PATH")
64+
if not event_path:
65+
return None
66+
try:
67+
with open(event_path, "r") as f:
68+
event = json.load(f)
69+
return event.get("pull_request", {}).get("title")
70+
except Exception as e:
71+
print(
72+
f"::warning::Failed to read PR title from event: {e}", file=sys.stderr
73+
)
74+
return None
75+
76+
5677
def parse_commit_messages(output: str) -> list[str]:
5778
"""Split git log output into individual commit messages."""
5879
return [
@@ -149,13 +170,23 @@ def run_check_command(
149170
return result.returncode
150171

151172

152-
def run_pr_message_checks(pr_messages: list[str], result_file: TextIO) -> int:
173+
def run_pr_message_checks(
174+
pr_messages: list[str],
175+
result_file: TextIO,
176+
initial_emitted: bool = False,
177+
) -> int:
153178
"""Checks each PR commit message individually via commit-check --message.
154179
180+
Parameters
181+
----------
182+
initial_emitted : bool
183+
Whether another check (e.g. PR title) has already produced banner output,
184+
so the first failing commit should use --no-banner.
185+
155186
Returns 1 if any message fails, 0 if all pass.
156187
"""
157188
has_failure = False
158-
emitted_failure_output = False
189+
emitted_failure_output = initial_emitted
159190
total = len(pr_messages)
160191
for index, msg in enumerate(pr_messages, start=1):
161192
command_args = ["--message"]
@@ -198,20 +229,59 @@ def build_check_args() -> list[str]:
198229

199230

200231
def run_commit_check() -> int:
201-
"""Runs the commit-check command and logs the result."""
232+
"""Runs all enabled checks and returns the overall exit code.
233+
234+
Checks are evaluated in order:
235+
1. PR title (when ``pr-title: true`` and in a PR event)
236+
2. Individual PR commit messages (when ``message: true`` and in a PR event)
237+
3. All remaining checks (branch, author name/email, etc.)
238+
239+
Outside of a PR event all enabled checks are handed to the CLI at once.
240+
"""
202241
args = build_check_args()
242+
exit_code = 0
243+
emitted_failure_output = False
244+
203245
with open("result.txt", "w") as result_file:
246+
# ---- 1. PR title check ------------------------------------------------
247+
if PR_TITLE_ENABLED and is_pr_event():
248+
pr_title = get_pr_title()
249+
if pr_title:
250+
cmd_args = ["--message"]
251+
output_prefix = None
252+
if emitted_failure_output:
253+
cmd_args.append("--no-banner")
254+
output_prefix = "\n---\n# PR Title\n"
255+
rc = run_check_command(
256+
cmd_args,
257+
result_file,
258+
input_text=pr_title,
259+
output_prefix=output_prefix,
260+
)
261+
if rc != 0:
262+
exit_code = max(exit_code, rc)
263+
emitted_failure_output = True
264+
265+
# ---- 2. Commit message checks -----------------------------------------
204266
if MESSAGE_ENABLED:
205267
pr_messages = get_pr_commit_messages()
206268
if pr_messages:
207-
# In PR context: check each commit message individually to avoid
269+
# In PR context: check each commit individually to avoid
208270
# 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
271+
rc = run_pr_message_checks(
272+
pr_messages, result_file, initial_emitted=emitted_failure_output
273+
)
274+
if rc != 0:
275+
exit_code = max(exit_code, rc)
276+
args = [a for a in args if a != "--message"]
277+
278+
# ---- 3. Remaining checks (branch, author, etc.) -----------------------
279+
if args:
280+
rc = run_other_checks(args, result_file)
281+
if rc != 0:
282+
exit_code = max(exit_code, rc)
283+
284+
return 1 if exit_code else 0
215285

216286

217287
def read_result_file() -> str | None:

‎main_test.py‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,70 @@ def test_splits_messages_and_trims_surrounding_newlines(self):
6767
self.assertEqual(result, ["fix: first", "feat: second"])
6868

6969

70+
class TestGetPrTitle(unittest.TestCase):
71+
def test_non_pr_event_returns_none(self):
72+
with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "push"}):
73+
self.assertIsNone(main.get_pr_title())
74+
75+
def test_pr_event_returns_title(self):
76+
import tempfile
77+
78+
event = {
79+
"pull_request": {"title": "feat: add login page"},
80+
}
81+
with tempfile.NamedTemporaryFile(
82+
mode="w", suffix=".json", delete=False
83+
) as f:
84+
json.dump(event, f)
85+
event_path = f.name
86+
with (
87+
patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request", "GITHUB_EVENT_PATH": event_path}),
88+
):
89+
self.assertEqual(main.get_pr_title(), "feat: add login page")
90+
os.unlink(event_path)
91+
92+
def test_pull_request_target_event(self):
93+
import tempfile
94+
95+
event = {
96+
"pull_request": {"title": "fix: resolve timeout"},
97+
}
98+
with tempfile.NamedTemporaryFile(
99+
mode="w", suffix=".json", delete=False
100+
) as f:
101+
json.dump(event, f)
102+
event_path = f.name
103+
with (
104+
patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request_target", "GITHUB_EVENT_PATH": event_path}),
105+
):
106+
self.assertEqual(main.get_pr_title(), "fix: resolve timeout")
107+
os.unlink(event_path)
108+
109+
def test_missing_event_path_returns_none(self):
110+
with (
111+
patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}),
112+
patch.dict(os.environ, {}, clear=True),
113+
):
114+
os.environ["GITHUB_EVENT_NAME"] = "pull_request"
115+
os.environ.pop("GITHUB_EVENT_PATH", None)
116+
self.assertIsNone(main.get_pr_title())
117+
118+
def test_invalid_json_returns_none(self):
119+
import tempfile
120+
121+
with tempfile.NamedTemporaryFile(
122+
mode="w", suffix=".json", delete=False
123+
) as f:
124+
f.write("not valid json")
125+
event_path = f.name
126+
with (
127+
patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request", "GITHUB_EVENT_PATH": event_path}),
128+
patch("builtins.print"),
129+
):
130+
self.assertIsNone(main.get_pr_title())
131+
os.unlink(event_path)
132+
133+
70134
class TestRunCheckCommand(unittest.TestCase):
71135
def test_with_args_calls_subprocess(self):
72136
mock_result = MagicMock(returncode=0, stdout="")
@@ -148,6 +212,32 @@ def test_first_failure_keeps_banner_and_later_failures_use_no_banner(self):
148212
["commit-check", "--message", "--no-banner"],
149213
)
150214

215+
def test_initial_emitted_suppresses_banner_for_first_failure(self):
216+
results = [
217+
MagicMock(returncode=1, stdout="Commit rejected.\n"),
218+
]
219+
with patch("main.subprocess.run", side_effect=results) as mock_run:
220+
main.run_pr_message_checks(
221+
["bad commit"], io.StringIO(), initial_emitted=True
222+
)
223+
self.assertEqual(
224+
mock_run.call_args[0][0],
225+
["commit-check", "--message", "--no-banner"],
226+
)
227+
228+
def test_initial_not_emitted_allows_banner(self):
229+
results = [
230+
MagicMock(returncode=1, stdout="Commit rejected.\n"),
231+
]
232+
with patch("main.subprocess.run", side_effect=results) as mock_run:
233+
main.run_pr_message_checks(
234+
["bad commit"], io.StringIO(), initial_emitted=False
235+
)
236+
self.assertEqual(
237+
mock_run.call_args[0][0],
238+
["commit-check", "--message"],
239+
)
240+
151241
def test_later_failure_prefix_uses_short_separator_without_extra_blank_lines(self):
152242
results = [
153243
MagicMock(returncode=0, stdout=""),
@@ -322,6 +412,75 @@ def test_pr_path_returns_nonzero_when_any_check_fails(self):
322412
rc = main.run_commit_check()
323413
self.assertEqual(rc, 1)
324414

415+
def test_pr_title_check_runs_when_enabled(self):
416+
with (
417+
patch("main.PR_TITLE_ENABLED", True),
418+
patch("main.MESSAGE_ENABLED", False),
419+
patch("main.BRANCH_ENABLED", False),
420+
patch("main.AUTHOR_NAME_ENABLED", False),
421+
patch("main.AUTHOR_EMAIL_ENABLED", False),
422+
patch("main.is_pr_event", return_value=True),
423+
patch("main.get_pr_title", return_value="feat: a feature"),
424+
patch("main.run_check_command", return_value=0) as mock_cmd,
425+
patch("main.run_other_checks", return_value=0),
426+
):
427+
rc = main.run_commit_check()
428+
self.assertEqual(rc, 0)
429+
self.assertEqual(
430+
mock_cmd.call_args[0][0], ["--message"],
431+
)
432+
self.assertEqual(mock_cmd.call_args[1]["input_text"], "feat: a feature")
433+
434+
def test_pr_title_failure_propagates(self):
435+
with (
436+
patch("main.PR_TITLE_ENABLED", True),
437+
patch("main.MESSAGE_ENABLED", False),
438+
patch("main.BRANCH_ENABLED", False),
439+
patch("main.AUTHOR_NAME_ENABLED", False),
440+
patch("main.AUTHOR_EMAIL_ENABLED", False),
441+
patch("main.is_pr_event", return_value=True),
442+
patch("main.get_pr_title", return_value="bad title"),
443+
patch("main.run_check_command", return_value=1),
444+
patch("main.run_other_checks", return_value=0),
445+
):
446+
rc = main.run_commit_check()
447+
self.assertEqual(rc, 1)
448+
449+
def test_pr_title_skipped_outside_pr_context(self):
450+
with (
451+
patch("main.PR_TITLE_ENABLED", True),
452+
patch("main.MESSAGE_ENABLED", False),
453+
patch("main.BRANCH_ENABLED", False),
454+
patch("main.AUTHOR_NAME_ENABLED", False),
455+
patch("main.AUTHOR_EMAIL_ENABLED", False),
456+
patch("main.is_pr_event", return_value=False),
457+
patch("main.get_pr_title") as mock_title,
458+
patch("main.run_check_command", return_value=0),
459+
patch("main.run_other_checks", return_value=0),
460+
):
461+
rc = main.run_commit_check()
462+
self.assertEqual(rc, 0)
463+
mock_title.assert_not_called()
464+
465+
def test_pr_title_and_message_both_run(self):
466+
with (
467+
patch("main.PR_TITLE_ENABLED", True),
468+
patch("main.MESSAGE_ENABLED", True),
469+
patch("main.BRANCH_ENABLED", False),
470+
patch("main.AUTHOR_NAME_ENABLED", False),
471+
patch("main.AUTHOR_EMAIL_ENABLED", False),
472+
patch("main.is_pr_event", return_value=True),
473+
patch("main.get_pr_title", return_value="feat: nice pr"),
474+
patch("main.get_pr_commit_messages", return_value=["fix: first", "feat: second"]),
475+
patch("main.run_check_command", return_value=0) as mock_cmd,
476+
patch("main.run_pr_message_checks", return_value=0) as mock_pr,
477+
patch("main.run_other_checks", return_value=0),
478+
):
479+
rc = main.run_commit_check()
480+
self.assertEqual(rc, 0)
481+
mock_cmd.assert_called_once() # PR title check
482+
mock_pr.assert_called_once() # commit message checks
483+
325484
def test_non_pr_path_uses_direct_command(self):
326485
with (
327486
patch("main.MESSAGE_ENABLED", True),

0 commit comments

Comments
 (0)