@@ -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+
70134class 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