"""Unit tests for scripts/ci/validate_commit_msg.py.""" import os import subprocess import tempfile from unittest.mock import MagicMock, patch from click.testing import CliRunner from devx.ci.validate_commit_msg import first_line, get_branch, get_latest_commit_msg, main from devx.config import CONVENTIONAL_RE, TASK_ID_RE 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("DEVX-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("DEVX-19: feat: something") assert TASK_ID_RE.match("DEVX-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 @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_rejects_task_id_on_feature_branch(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("DEVX-19: feat: add feature") with patch("devx.ci.validate_commit_msg.get_branch", return_value="DEVX-19"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 assert "task ID" in result.output @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_accepts_conventional_on_feature_branch(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("feat: add feature") with patch("devx.ci.validate_commit_msg.get_branch", return_value="DEVX-19"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_accepts_valid_master_commit(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("DEVX-19: feat: add feature") with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_rejects_master_without_task_id(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("feat: add feature") with patch("devx.ci.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 @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_rejects_master_with_non_conventional_after_task_id(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("DEVX-19: random message") with patch("devx.ci.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 @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_rejects_non_conventional_on_feature_branch(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("random message") with patch("devx.ci.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 @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_accepts_multiline_conventional(self, mock_commit: MagicMock) -> None: msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.") with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 @patch("devx.ci.validate_commit_msg.get_branch") @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_usage_message_without_args(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 @patch("devx.ci.validate_commit_msg.get_branch") @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_branch_override_accepts_master_commit(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None: """--branch master overrides branch detection (for CI use).""" msg_path = self._write_msg("DEVX-19: feat: add feature") runner = CliRunner() result = runner.invoke(main, [msg_path, "--branch", "master"]) assert result.exit_code == 0 @patch("devx.ci.validate_commit_msg.get_branch") @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_branch_override_rejects_missing_task_id(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None: """--branch master still enforces DEVX-N: prefix.""" msg_path = self._write_msg("feat: add feature") runner = CliRunner() result = runner.invoke(main, [msg_path, "--branch", "master"]) assert result.exit_code == 1 assert "task ID" in result.output @patch("devx.ci.validate_commit_msg.get_branch") @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_branch_override_feature_accepts_conventional(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None: """--branch feature still rejects DEVX-N prefix.""" msg_path = self._write_msg("DEVX-19: feat: add feature") runner = CliRunner() result = runner.invoke(main, [msg_path, "--branch", "feature"]) assert result.exit_code == 1 assert "task ID" in result.output class TestCustomPrefix: """Tests for custom task ID prefix (e.g., GRM-N instead of DEVX-N). The prefix is configured via the DEVX_TASK_PREFIX environment variable. This is critical for consumer projects like GRM that use their own Vikunja project with a different identifier prefix. """ def _write_msg(self, content: str) -> str: fd, path = tempfile.mkstemp() with os.fdopen(fd, "w") as f: f.write(content) return path @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") @patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"}) def test_master_accepts_proj_prefix(self, mock_commit: MagicMock) -> None: """Master branch accepts PROJ-N: prefix when DEVX_TASK_PREFIX=GRM.""" import importlib import devx.ci.validate_commit_msg as vcm import devx.config importlib.reload(devx.config) importlib.reload(vcm) try: msg_path = self._write_msg("PROJ-66: fix: add scripts/** to infrastructure") with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(vcm.main, [msg_path]) assert result.exit_code == 0 os.unlink(msg_path) finally: os.environ.pop("DEVX_TASK_PREFIX", None) importlib.reload(devx.config) importlib.reload(vcm) @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") @patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"}) def test_master_rejects_devx_prefix_when_proj_configured(self, mock_commit: MagicMock) -> None: """Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM.""" import importlib import devx.ci.validate_commit_msg as vcm import devx.config importlib.reload(devx.config) importlib.reload(vcm) try: msg_path = self._write_msg("DEVX-8: fix: wrong prefix") with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(vcm.main, [msg_path]) assert result.exit_code == 1 assert "PROJ-N" in result.output os.unlink(msg_path) finally: os.environ.pop("DEVX_TASK_PREFIX", None) importlib.reload(devx.config) importlib.reload(vcm) @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") @patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"}) def test_feature_branch_rejects_proj_prefix(self, mock_commit: MagicMock) -> None: """Feature branch rejects PROJ-N: prefix when DEVX_TASK_PREFIX=GRM.""" import importlib import devx.ci.validate_commit_msg as vcm import devx.config importlib.reload(devx.config) importlib.reload(vcm) try: msg_path = self._write_msg("PROJ-66: fix: should not have prefix on branch") with patch("devx.ci.validate_commit_msg.get_branch", return_value="PROJ-66-fix"): runner = CliRunner() result = runner.invoke(vcm.main, [msg_path]) assert result.exit_code == 1 assert "task ID" in result.output os.unlink(msg_path) finally: os.environ.pop("DEVX_TASK_PREFIX", None) importlib.reload(devx.config) importlib.reload(vcm) def test_main_module_block() -> None: import tempfile with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("DEVX-1: feat: test") msg_path = f.name with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): import devx.ci.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) class TestGitMode: def test_git_flag_reads_from_git(self, tmp_path) -> None: with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value="feat: add feature"): with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"): runner = CliRunner() result = runner.invoke(main, ["--git"]) assert result.exit_code == 0 def test_git_flag_master_valid(self) -> None: msg = "DEVX-24: fix: resolve timeout" with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg): with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, ["--git", "--branch", "master"]) assert result.exit_code == 0 def test_git_flag_master_invalid(self) -> None: msg = "fix: resolve timeout" with patch("devx.ci.validate_commit_msg.get_latest_commit_msg", return_value=msg): with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, ["--git", "--branch", "master"]) assert result.exit_code != 0 @patch("devx.ci.validate_commit_msg.get_branch") @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_no_file_no_git_raises(self, mock_commit: MagicMock, mock_branch: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--branch", "master"]) assert result.exit_code != 0 def test_get_latest_commit_msg_success(self) -> None: with patch("subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: test\n\nBody") result = get_latest_commit_msg() assert result == "feat: test\n\nBody" @patch("devx.ci.validate_commit_msg.get_latest_commit_msg") def test_stdin_input(self, mock_commit: MagicMock) -> None: with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature-branch"): runner = CliRunner() result = runner.invoke(main, input="feat: add feature\n", args=["-", "--branch", "feature-branch"]) assert result.exit_code == 0