"""Unit tests for scripts/ci/post_merge.py.""" import http import subprocess from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from devx.ci.post_merge import ( _get_git_commit_message, _get_git_commit_sha, build_comment, extract_conventional_msg, extract_task_id, main, resolve_task_id, ) from devx.exceptions import APIError class TestExtractTaskId: def test_extracts_from_first_line(self) -> None: assert extract_task_id("DEVX-19: fix: resolve bug\n\nBody") == "DEVX-19" def test_returns_empty_when_missing(self) -> None: assert extract_task_id("fix: resolve bug") == "" class TestExtractConventionalMsg: def test_strips_colon_prefix(self) -> None: """Legacy format: DEVX-N: """ assert extract_conventional_msg("DEVX-19: fix: resolve bug") == "fix: resolve bug" def test_strips_space_prefix(self) -> None: """Current format: DEVX-N """ assert extract_conventional_msg("DEVX-19 fix: resolve bug") == "fix: resolve bug" def test_returns_unchanged_without_prefix(self) -> None: assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug" class TestBuildComment: def test_html_format(self) -> None: html = build_comment("DEVX-19", "fix: bug", "abc123") assert "DEVX-19" in html assert "fix: bug" in html assert "abc123" in html class TestResolveTaskId: def test_found(self) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 42, "identifier": "DEVX-19"}, ] assert resolve_task_id(mock_client, "DEVX-19") == 42 mock_client.list_project_tasks.assert_called_once() def test_not_found_raises(self) -> None: """Missing Vikunja task is a fatal error — every PR must have a task.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] with pytest.raises(click.ClickException, match="Could not find"): resolve_task_id(mock_client, "DEVX-99") def test_found_on_second_page(self) -> None: """Task is on page 2 when project has more than 50 tasks.""" mock_client = MagicMock() page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(50)] page2 = [{"id": 100, "identifier": "DEVX-99"}] mock_client.list_project_tasks.side_effect = [page1, page2] assert resolve_task_id(mock_client, "DEVX-99") == 100 assert mock_client.list_project_tasks.call_count == 2 def test_stops_when_page_is_partial(self) -> None: """Stops paginating when a page has fewer than DEFAULT_PER_PAGE results.""" mock_client = MagicMock() page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(10)] mock_client.list_project_tasks.return_value = page1 with pytest.raises(click.ClickException, match="Could not find"): resolve_task_id(mock_client, "DEVX-99") assert mock_client.list_project_tasks.call_count == 1 def test_http_error_propagates(self) -> None: mock_client = MagicMock() mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") with pytest.raises(APIError): resolve_task_id(mock_client, "DEVX-19") class TestMain: @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_full_flow( self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke( main, ["DEVX-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"], ) assert result.exit_code == 0 assert "updated and marked done" in result.output mock_client.post_comment.assert_called_once() mock_client.update_task.assert_called_once_with(267, done=True) @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_no_commit_sha( self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["DEVX-20: fix: resolve bug"]) assert result.exit_code == 0 mock_client.post_comment.assert_called_once() args, _ = mock_client.post_comment.call_args assert "unknown" in args[1] @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True) def test_missing_token_exits(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["DEVX-20: fix: bug"]) assert result.exit_code == 1 assert "VIKUNJA_TOKEN" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_no_task_id_non_release_fails( self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: """Non-release commits without DEVX-N prefix should fail.""" runner = CliRunner() result = runner.invoke(main, ["fix: resolve bug"]) assert result.exit_code != 0 assert "No task ID" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_release_commit_without_task_id_skips( self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: """Release commits without DEVX-N prefix should skip gracefully.""" runner = CliRunner() result = runner.invoke(main, ["release: v0.3.2"]) assert result.exit_code == 0 assert "skipping" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_revert_commit_skips(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None: """Revert commits without DEVX-N prefix should skip gracefully.""" runner = CliRunner() result = runner.invoke(main, ["revert: remove v0.6.0 release"]) assert result.exit_code == 0 assert "Infrastructure commit" in result.output assert "skipping" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_merge_commit_skips(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None: """Merge commits without DEVX-N prefix should skip gracefully.""" runner = CliRunner() result = runner.invoke(main, ["Merge pull request #42"]) assert result.exit_code == 0 assert "Infrastructure commit" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_resolve_failure_fails( self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: """Missing Vikunja task is a fatal error — every PR must have a task.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["DEVX-20: fix: bug"]) assert result.exit_code != 0 assert "Could not find" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_post_comment_failure_fails( self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: """Vikunja API errors should fail — the task was not updated.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["DEVX-20: fix: bug"]) assert result.exit_code != 0 assert "Vikunja API error" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_mark_done_failure_fails( self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock ) -> None: """Vikunja API errors should fail — the task was not updated.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client.post_comment.return_value = None mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["DEVX-20: fix: bug"]) assert result.exit_code != 0 assert "Vikunja API error" in result.output class TestGetGitCommitMessage: def test_success(self) -> None: mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="") with patch("subprocess.run", return_value=mock_result): assert _get_git_commit_message() == "DEVX-20: fix: bug" def test_failure(self) -> None: mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error") with patch("subprocess.run", return_value=mock_result): with pytest.raises(click.ClickException, match="git log failed"): _get_git_commit_message() class TestGetGitCommitSha: def test_success(self) -> None: mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="") with patch("subprocess.run", return_value=mock_result): assert _get_git_commit_sha() == "abc123" def test_failure(self) -> None: mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error") with patch("subprocess.run", return_value=mock_result): with pytest.raises(click.ClickException, match="git rev-parse failed"): _get_git_commit_sha() class TestFromGit: @patch("devx.ci.post_merge.subprocess.run") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123") @patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug") def test_from_git( self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock, mock_subproc: MagicMock ) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["--from-git"]) assert result.exit_code == 0 assert "updated and marked done" in result.output @patch("devx.ci.post_merge.subprocess.run") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123") @patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug") def test_from_git_with_explicit_sha( self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock, mock_subproc: MagicMock ) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"]) assert result.exit_code == 0 @patch("devx.ci.post_merge.subprocess.run") @patch("devx.ci.post_merge._get_git_commit_message", return_value="msg") @patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_no_msg_and_no_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "commit_msg" in result.output class TestGitSha: """Tests for the --git-sha option (race condition fix).""" @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None: """--git-sha reads commit message from a specific SHA, not HEAD.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client_cls.return_value = mock_client mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="") with patch("subprocess.run", return_value=mock_result): runner = CliRunner() result = runner.invoke(main, ["--git-sha", "abc123"]) assert result.exit_code == 0 assert "updated and marked done" in result.output mock_client.post_comment.assert_called_once() # Verify the SHA was passed to the comment args, _ = mock_client.post_comment.call_args assert "abc123" in args[1] @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_git_sha_failure_raises(self) -> None: """--git-sha with invalid SHA should raise.""" mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha") with patch("subprocess.run", return_value=mock_result): runner = CliRunner() result = runner.invoke(main, ["--git-sha", "badsha"]) assert result.exit_code != 0 assert "git log failed" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) @patch("devx.ci.post_merge.VikunjaClient") def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None: """--git-sha with --commit-sha uses the explicit SHA for the comment.""" mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ {"id": 267, "identifier": "DEVX-20"}, ] mock_client_cls.return_value = mock_client mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="") with patch("subprocess.run", return_value=mock_result): runner = CliRunner() result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"]) assert result.exit_code == 0 args, _ = mock_client.post_comment.call_args assert "explicit_sha" in args[1]