"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number.""" from __future__ import annotations from unittest.mock import MagicMock, patch from click.testing import CliRunner from devx.tools.pr_rebase import main as pr_rebase_main from devx.tools.rebase import main as rebase_main _FULL_ENV = { "CI_GITEA_TOKEN": "tok", "DEVX_REPO_OWNER": "owner", "DEVX_REPO_NAME": "repo", } class TestRunGitHelper: """Tests for the _run_git helper function.""" @patch("devx.tools.rebase.subprocess.run") def test_run_git_with_check(self, mock_run: MagicMock) -> None: """_run_git passes check=True by default.""" from devx.tools.rebase import _run_git mock_run.return_value = MagicMock(stdout="ok\n", returncode=0) result = _run_git(["status"]) mock_run.assert_called_once_with( ["git", "status"], capture_output=True, text=True, check=True, ) assert result.stdout == "ok\n" @patch("devx.tools.rebase.subprocess.run") def test_run_git_without_check(self, mock_run: MagicMock) -> None: """_run_git passes check=False when specified.""" from devx.tools.rebase import _run_git mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1) result = _run_git(["rebase", "origin/master"], check=False) mock_run.assert_called_once_with( ["git", "rebase", "origin/master"], capture_output=True, text=True, check=False, ) assert result.returncode == 1 class TestDetectPrNumber: """Tests for the detect_pr_number helper in _shared.""" @patch("devx.tools._shared.subprocess.run") @patch.dict("os.environ", _FULL_ENV, clear=True) @patch("devx.api_clients.GiteaClient") def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: """detect_pr_number returns PR number when branch has an open PR.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0) mock_client = MagicMock() mock_client.list_prs.return_value = [ {"number": 42, "head": {"ref": "feature-branch"}}, {"number": 99, "head": {"ref": "other-branch"}}, ] mock_client_cls.return_value = mock_client result = detect_pr_number() assert result == 42 @patch("devx.tools._shared.subprocess.run") @patch.dict("os.environ", _FULL_ENV, clear=True) @patch("devx.api_clients.GiteaClient") def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: """detect_pr_number returns None when no open PR matches branch.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0) mock_client = MagicMock() mock_client.list_prs.return_value = [ {"number": 42, "head": {"ref": "other-branch"}}, ] mock_client_cls.return_value = mock_client result = detect_pr_number() assert result is None @patch("devx.tools._shared.subprocess.run") def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None: """detect_pr_number returns None on detached HEAD.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0) result = detect_pr_number() assert result is None @patch("devx.tools._shared.subprocess.run") def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None: """detect_pr_number returns None when git command fails.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1) result = detect_pr_number() assert result is None @patch("devx.tools._shared.subprocess.run") @patch.dict("os.environ", {}, clear=True) def test_detect_pr_no_token(self, mock_run: MagicMock) -> None: """detect_pr_number returns None when CI_GITEA_TOKEN is not set.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) result = detect_pr_number() assert result is None @patch("devx.tools._shared.subprocess.run") @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True) @patch("devx.api_clients.GiteaClient") def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: """detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) mock_client = MagicMock() mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}] mock_client_cls.return_value = mock_client result = detect_pr_number() assert result == 7 @patch("devx.tools._shared.subprocess.run") @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True) def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None: """GITHUB_REPOSITORY without slash is ignored, returns None.""" from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) result = detect_pr_number() assert result is None @patch("devx.tools._shared.subprocess.run") @patch.dict("os.environ", _FULL_ENV, clear=True) @patch("devx.api_clients.GiteaClient") def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: """detect_pr_number returns None when API call fails (best-effort).""" from devx.api_clients import APIError from devx.tools._shared import detect_pr_number mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) mock_client = MagicMock() mock_client.list_prs.side_effect = APIError(401, "Unauthorized") mock_client_cls.return_value = mock_client result = detect_pr_number() assert result is None class TestRebaseTool: """Tests for the local rebase tool (devx.tools.rebase).""" @patch("devx.tools.rebase._run_git") def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None: """When branch is up-to-date, no rebase or push happens.""" mock_run_git.side_effect = [ MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse MagicMock(stdout="", returncode=0), # fetch MagicMock(stdout="0\n", returncode=0), # rev-list --count ] runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code == 0 assert "already up-to-date" in result.output.lower() @patch("devx.tools.rebase._run_git") def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None: """When behind master, rebase and force-push.""" mock_run_git.side_effect = [ MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse MagicMock(stdout="", returncode=0), # fetch MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2) MagicMock(stdout="", stderr="", returncode=0), # rebase MagicMock(stdout="", stderr="", returncode=0), # push ] runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code == 0 assert "2 commit(s) behind" in result.output assert "rebase successful" in result.output.lower() assert "pushed" in result.output.lower() @patch("devx.tools.rebase._run_git") def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None: """With --no-push, rebase happens but no push.""" mock_run_git.side_effect = [ MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse MagicMock(stdout="", returncode=0), # fetch MagicMock(stdout="1\n", returncode=0), # rev-list --count MagicMock(stdout="", stderr="", returncode=0), # rebase ] runner = CliRunner() result = runner.invoke(rebase_main, ["--no-push"]) assert result.exit_code == 0 assert "rebase successful" in result.output.lower() # Only 4 git calls (no push) assert mock_run_git.call_count == 4 @patch("devx.tools.rebase._run_git") def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None: """Detached HEAD should fail immediately.""" mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0) runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code != 0 assert "detached" in result.output.lower() @patch("devx.tools.rebase._run_git") def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None: """Git rev-parse failure should exit with error.""" mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1) runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code != 0 assert "could not detect" in result.output.lower() @patch("devx.tools.rebase._run_git") def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None: """Rebase conflict should exit with error.""" mock_run_git.side_effect = [ MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse MagicMock(stdout="", returncode=0), # fetch MagicMock(stdout="1\n", returncode=0), # rev-list --count MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails ] runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code != 0 assert "rebase failed" in result.output.lower() @patch("devx.tools.rebase._run_git") def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None: """Fetch failure should exit with error.""" mock_run_git.side_effect = [ MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails ] runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code != 0 assert "fetch failed" in result.output.lower() @patch("devx.tools.rebase._run_git") def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None: """Force-push rejection should exit with error.""" mock_run_git.side_effect = [ MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse MagicMock(stdout="", returncode=0), # fetch MagicMock(stdout="1\n", returncode=0), # rev-list --count MagicMock(stdout="", stderr="", returncode=0), # rebase MagicMock(stdout="", stderr="rejected", returncode=1), # push fails ] runner = CliRunner() result = runner.invoke(rebase_main, []) assert result.exit_code != 0 assert "force-push failed" in result.output.lower() class TestPrRebaseTool: """Tests for the server-side PR rebase tool (devx.tools.pr_rebase).""" @patch("devx.tools._shared.detect_pr_number") @patch.dict("os.environ", _FULL_ENV, clear=True) @patch("devx.tools.pr_rebase.GiteaClient") def test_pr_rebase_success(self, mock_client_cls: MagicMock, mock_detect: MagicMock) -> None: """Successful API rebase prints confirmation.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(pr_rebase_main, ["--pr", "42"]) assert result.exit_code == 0 assert "rebased successfully" in result.output.lower() mock_client.update_pr_branch.assert_called_once_with(42, style="rebase") @patch("devx.tools._shared.detect_pr_number") @patch.dict("os.environ", _FULL_ENV, clear=True) @patch("devx.tools.pr_rebase.GiteaClient") def test_pr_rebase_api_error(self, mock_client_cls: MagicMock, mock_detect: MagicMock) -> None: """API error during rebase exits with error.""" from devx.api_clients import APIError mock_client = MagicMock() mock_client.update_pr_branch.side_effect = APIError(409, "Conflict") mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(pr_rebase_main, ["--pr", "42"]) assert result.exit_code != 0 assert "rebase failed" in result.output.lower() @patch("devx.tools._shared.detect_pr_number") @patch("devx.tools.pr_rebase.load_dotenv") @patch.dict("os.environ", {}, clear=True) def test_pr_rebase_no_token(self, _mock_load: MagicMock, mock_detect: MagicMock) -> None: """Missing CI_GITEA_TOKEN should fail.""" runner = CliRunner() result = runner.invoke(pr_rebase_main, ["--pr", "42"]) assert result.exit_code != 0 assert "CI_GITEA_TOKEN" in result.output @patch.dict("os.environ", _FULL_ENV, clear=True) @patch("devx.tools.pr_rebase.detect_pr_number", return_value=None) def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None: """When PR number can't be auto-detected, fail with instructions.""" runner = CliRunner() result = runner.invoke(pr_rebase_main, []) assert result.exit_code != 0 assert "could not detect" in result.output.lower() @patch("devx.tools._shared.detect_pr_number") @patch("devx.tools.pr_rebase.load_dotenv") @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) @patch("devx.tools.pr_rebase.GiteaClient") def test_pr_rebase_no_repo_env( self, _mock_client: MagicMock, _mock_load: MagicMock, mock_detect: MagicMock ) -> None: """Missing repo env vars should fail.""" runner = CliRunner() result = runner.invoke(pr_rebase_main, ["--pr", "42"]) assert result.exit_code != 0 assert "DEVX_REPO_OWNER" in result.output @patch("devx.tools._shared.detect_pr_number") @patch("devx.tools.pr_rebase.load_dotenv") @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True) @patch("devx.tools.pr_rebase.GiteaClient") def test_pr_rebase_github_repo_fallback( self, mock_client_cls: MagicMock, _mock_load: MagicMock, mock_detect: MagicMock ) -> None: """GITHUB_REPOSITORY env var is used as fallback for owner/repo.""" mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(pr_rebase_main, ["--pr", "42"]) assert result.exit_code == 0 mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")