Public Access
323 lines
14 KiB
Python
323 lines
14 KiB
Python
"""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
|