"""Unit tests for devx.ci.check_auto_merge_ready.""" from unittest.mock import MagicMock, patch from click.testing import CliRunner from devx.ci.check_auto_merge_ready import ( cli, get_pr_title_from_gitea, get_vikunja_title_optional, is_branch_behind_master, ) class TestIsBranchBehindMaster: @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_false_when_ahead(self, mock_run: MagicMock) -> None: # First: fetch (ok), second: ahead count (ok), third: behind count = 0 mock_run.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), MagicMock(returncode=0, stdout="3\n", stderr=""), MagicMock(returncode=0, stdout="0\n", stderr=""), ] assert is_branch_behind_master("feature") is False @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_true_when_behind(self, mock_run: MagicMock) -> None: mock_run.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), MagicMock(returncode=0, stdout="0\n", stderr=""), MagicMock(returncode=0, stdout="5\n", stderr=""), ] assert is_branch_behind_master("feature") is True @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_false_on_git_error(self, mock_run: MagicMock) -> None: mock_run.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), MagicMock(returncode=1, stdout="", stderr="error"), ] assert is_branch_behind_master("feature") is False @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_false_on_timeout(self, mock_run: MagicMock) -> None: import subprocess mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=30) assert is_branch_behind_master("feature") is False @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_false_on_value_error(self, mock_run: MagicMock) -> None: mock_run.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), MagicMock(returncode=0, stdout="3\n", stderr=""), MagicMock(returncode=0, stdout="not_a_number\n", stderr=""), ] assert is_branch_behind_master("feature") is False @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_false_on_file_not_found(self, mock_run: MagicMock) -> None: mock_run.side_effect = FileNotFoundError("git not found") assert is_branch_behind_master("feature") is False @patch("devx.ci.check_auto_merge_ready.subprocess.run") def test_returns_false_when_behind_check_fails(self, mock_run: MagicMock) -> None: # fetch ok, ahead count ok, behind count command fails mock_run.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), MagicMock(returncode=0, stdout="3\n", stderr=""), MagicMock(returncode=1, stdout="", stderr="error"), ] assert is_branch_behind_master("feature") is False class TestGetPrTitleFromGitea: def test_returns_none_without_token(self) -> None: with patch.dict("os.environ", {}, clear=True): assert get_pr_title_from_gitea("owner/repo", 1) is None def test_returns_none_with_invalid_repo(self) -> None: with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True): assert get_pr_title_from_gitea("invalid", 1) is None @patch("devx.ci.check_auto_merge_ready.GiteaClient") def test_fetches_title(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"} mock_client_cls.return_value = mock_client with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True): result = get_pr_title_from_gitea("owner/repo", 1) assert result == "DEVX-1: Fix bug" @patch("devx.ci.check_auto_merge_ready.GiteaClient") def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None: from devx.exceptions import APIError mock_client = MagicMock() mock_client.get_pr.side_effect = APIError(500, "API error") mock_client_cls.return_value = mock_client with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True): result = get_pr_title_from_gitea("owner/repo", 1) assert result is None class TestGetVikunjaTitleOptional: def test_returns_none_without_token(self) -> None: with patch.dict("os.environ", {}, clear=True): assert get_vikunja_title_optional("DEVX-1") is None @patch("devx.ci.check_auto_merge_ready.VikunjaClient") def test_returns_title_when_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-1", "title": "Fix bug"}] mock_client_cls.return_value = mock_client with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): result = get_vikunja_title_optional("DEVX-1") assert result == "Fix bug" @patch("devx.ci.check_auto_merge_ready.VikunjaClient") def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-2", "title": "Other task"}] mock_client_cls.return_value = mock_client with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): result = get_vikunja_title_optional("DEVX-1") assert result is None @patch("devx.ci.check_auto_merge_ready.VikunjaClient") def test_paginates_until_found(self, mock_client_cls: MagicMock) -> None: from devx.config import DEFAULT_PER_PAGE mock_client = MagicMock() # First page: full page of non-matching tasks, second page: match page1 = [{"identifier": f"DEVX-{i}", "title": f"Task {i}"} for i in range(DEFAULT_PER_PAGE)] page2 = [{"identifier": "DEVX-99", "title": "Found it"}] mock_client.list_project_tasks.side_effect = [page1, page2] mock_client_cls.return_value = mock_client with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): result = get_vikunja_title_optional("DEVX-99") assert result == "Found it" @patch("devx.ci.check_auto_merge_ready.VikunjaClient") def test_returns_none_when_empty_first_page(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] mock_client_cls.return_value = mock_client with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True): result = get_vikunja_title_optional("DEVX-1") assert result is None class TestCli: def test_fails_without_task_id(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), ): result = runner.invoke(cli, ["--branch", "no-task-id-here"]) assert result.exit_code != 0 def test_local_mode_no_pr_title(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), ): result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo"]) assert result.exit_code == 0 assert "local mode" in result.output def test_validates_pr_title_format(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), ): result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "Bad title"]) assert result.exit_code != 0 assert "format" in result.output.lower() or "mismatch" in result.output.lower() def test_passes_with_valid_title(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), ): result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"]) assert result.exit_code == 0 assert "satisfied" in result.output def test_skip_behind_check(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-behind-check"], ) assert result.exit_code == 0 def test_fails_when_behind_master(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], ) assert result.exit_code != 0 assert "behind" in result.output.lower() def test_skip_vikunja(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-vikunja"], ) assert result.exit_code == 0 def test_fetches_pr_title_from_gitea(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value="DEVX-1: Fix foo"), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"], ) assert result.exit_code == 0 assert "from Gitea" in result.output def test_fails_when_pr_number_but_no_title(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"], ) assert result.exit_code != 0 assert "Could not fetch" in result.output def test_fails_when_vikunja_token_set_but_task_not_found(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value=None), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], ) assert result.exit_code != 0 assert "Could not find Vikunja task" in result.output def test_passes_with_vikunja_title_match(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Fix foo"), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], ) assert result.exit_code == 0 assert "Vikunja title match OK" in result.output def test_fails_with_vikunja_title_mismatch(self) -> None: runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], ) assert result.exit_code != 0 assert "does not match Vikunja" in result.output def test_fails_with_double_prefix_in_vikunja_title(self) -> None: """Vikunja title with task ID prefix causes double-prefix in PR title.""" runner = CliRunner() with ( patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True), patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False), patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="DEVX-1: Fix foo"), ): result = runner.invoke( cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"], ) assert result.exit_code != 0 assert "should NOT include" in result.output