"""Unit tests for scripts/validate_commit_msg.py.""" import os import subprocess import tempfile from unittest.mock import patch from click.testing import CliRunner from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE from scripts.validate_commit_msg import first_line, get_branch, main class TestHelpers: def test_first_line_single(self) -> None: assert first_line("feat: add something") == "feat: add something" def test_first_line_multiline(self) -> None: msg = "feat: add something\n\nBody text here.\nMore body." assert first_line(msg) == "feat: add something" def test_conventional_re_matches_valid(self) -> None: assert CONVENTIONAL_RE.match("feat: add feature") assert CONVENTIONAL_RE.match("fix: bug fix") assert CONVENTIONAL_RE.match("chore: update deps") assert CONVENTIONAL_RE.match("docs: update readme") assert CONVENTIONAL_RE.match("style: format code") assert CONVENTIONAL_RE.match("refactor: simplify") assert CONVENTIONAL_RE.match("perf: speed up") assert CONVENTIONAL_RE.match("test: add tests") assert CONVENTIONAL_RE.match("ci: update workflow") assert CONVENTIONAL_RE.match("build: update deps") assert CONVENTIONAL_RE.match("revert: undo change") def test_conventional_re_allows_scope(self) -> None: assert CONVENTIONAL_RE.match("feat(cli): add --url option") assert CONVENTIONAL_RE.match("fix(api): handle timeout") def test_conventional_re_rejects_invalid(self) -> None: assert not CONVENTIONAL_RE.match("GRM-19: feat: something") assert not CONVENTIONAL_RE.match("random message") assert not CONVENTIONAL_RE.match("feat:") assert not CONVENTIONAL_RE.match(": description") def test_task_id_re_matches(self) -> None: assert TASK_ID_RE.match("GRM-19: feat: something") assert TASK_ID_RE.match("GRM-123: fix: bug") def test_task_id_re_rejects(self) -> None: assert not TASK_ID_RE.match("feat: something") assert not TASK_ID_RE.match("GRM: something") class TestGetBranch: def test_returns_branch_name(self) -> None: with patch("subprocess.run") as mock_run: mock_run.return_value.stdout = "feature-branch\n" mock_run.return_value.returncode = 0 assert get_branch() == "feature-branch" def test_returns_empty_on_error(self) -> None: with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")): assert get_branch() == "" class TestMain: def _write_msg(self, content: str) -> str: fd, path = tempfile.mkstemp() with os.fdopen(fd, "w") as f: f.write(content) return path def test_rejects_task_id_on_feature_branch(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 assert "task ID" in result.output def test_accepts_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 def test_accepts_valid_master_commit(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 def test_rejects_master_without_task_id(self) -> None: msg_path = self._write_msg("feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 assert "task ID" in result.output def test_rejects_master_with_non_conventional_after_task_id(self) -> None: msg_path = self._write_msg("GRM-19: random message") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 assert "conventional" in result.output def test_rejects_non_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("random message") with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 assert "conventional" in result.output def test_accepts_multiline_conventional(self) -> None: msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.") with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 def test_usage_message_without_args(self) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 2 def test_main_module_block() -> None: import tempfile with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("GRM-1: feat: test") msg_path = f.name with patch("scripts.validate_commit_msg.get_branch", return_value="master"): import scripts.validate_commit_msg as vcm with open(vcm.__file__) as f: source = f.read() # Remove __main__ block so exec doesn't call main() before we control sys.argv source = source.replace('if __name__ == "__main__":\n main()\n', "") namespace = dict(vcm.__dict__) exec(compile(source, vcm.__file__, "exec"), namespace) # exec() redefines get_branch() from source, overwriting the mock. # Restore the patched mock so main() uses it. namespace["get_branch"] = vcm.get_branch namespace["main"]([msg_path], standalone_mode=False) os.unlink(msg_path)