"""Unit tests for devx.tools.create_pr.""" from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from devx.tools.create_pr import ( cli, create_pr, extract_task_id, find_existing_pr, get_repo_name, get_vikunja_task_title, ) class TestExtractTaskId: def test_valid(self) -> None: assert extract_task_id("DEVX-42-fix") == "DEVX-42" def test_invalid(self) -> None: assert extract_task_id("feature") == "" class TestGetRepoName: @patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"}) def test_from_env(self) -> None: assert get_repo_name() == "infra" @patch("devx.tools.create_pr.REPO_NAME", "devx") @patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True) def test_env_overrides_pyproject(self) -> None: assert get_repo_name() == "infra" @patch("devx.tools.create_pr.REPO_NAME", "devx") @patch.dict("os.environ", {}, clear=True) def test_from_pyproject(self) -> None: assert get_repo_name() == "devx" @patch("devx.tools.create_pr.REPO_NAME", "") @patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True) def test_from_github(self) -> None: assert get_repo_name() == "infra" @patch("devx.tools.create_pr.REPO_NAME", "") @patch.dict("os.environ", {}, clear=True) def test_missing_raises(self) -> None: with pytest.raises(click.ClickException, match="Repository name"): get_repo_name() class TestGetVikunjaTaskTitle: @patch("devx.tools.create_pr.VikunjaClient") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42", "title": "Add feature"} mock_client_cls.return_value = mock_client assert get_vikunja_task_title("DEVX-42") == "Add feature" @patch.dict("os.environ", {}, clear=True) def test_no_token(self) -> None: with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"): get_vikunja_task_title("DEVX-42") @patch("devx.tools.create_pr.VikunjaClient") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) def test_not_found(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.find_task_by_identifier.return_value = None mock_client_cls.return_value = mock_client with pytest.raises(click.ClickException, match="Could not find"): get_vikunja_task_title("DEVX-42") class TestFindExistingPr: def test_found(self) -> None: client = MagicMock() client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}] result = find_existing_pr(client, "DEVX-42-fix") assert result is not None assert result["number"] == 10 def test_not_found(self) -> None: client = MagicMock() client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}] result = find_existing_pr(client, "DEVX-42-fix") assert result is None class TestCreatePr: @patch("devx.tools.create_pr.GiteaClient") @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") @patch("devx.tools.create_pr.find_existing_pr", return_value=None) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: mock_client = MagicMock() mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"} mock_gitea.return_value = mock_client result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo") assert result["number"] == 15 mock_client.create_pr.assert_called_once_with( title="DEVX-42: Add feature", head="DEVX-42-fix", base="master", body="body", ) @patch("devx.tools.create_pr.GiteaClient") @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") @patch("devx.tools.create_pr.find_existing_pr") @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"} mock_client = MagicMock() mock_gitea.return_value = mock_client result = create_pr("DEVX-42-fix", "master", "", "owner", "repo") assert result["number"] == 10 mock_client.create_pr.assert_not_called() @patch.dict("os.environ", {}, clear=True) def test_no_repo_token(self) -> None: with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"): create_pr("DEVX-42-fix", "master", "", "owner", "repo") @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) def test_no_task_id_in_branch(self) -> None: with pytest.raises(click.ClickException, match="does not contain a task ID"): create_pr("feature-branch", "master", "", "owner", "repo") class TestCli: @patch("devx.tools.create_pr.create_pr") @patch("devx.tools.create_pr.subprocess.run") @patch("devx.tools.create_pr.REPO_OWNER", "owner") @patch("devx.tools.create_pr.get_repo_name", return_value="repo") def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None: mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0) mock_create.return_value = {"number": 1} runner = CliRunner() result = runner.invoke(cli, []) assert result.exit_code == 0 mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo") @patch("devx.tools.create_pr.create_pr") @patch("devx.tools.create_pr.REPO_OWNER", "owner") @patch("devx.tools.create_pr.get_repo_name", return_value="repo") def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None: mock_create.return_value = {"number": 1} runner = CliRunner() result = runner.invoke(cli, ["--branch", "DEVX-42-fix"]) assert result.exit_code == 0 @patch("devx.tools.create_pr.create_pr") @patch("devx.tools.create_pr.REPO_OWNER", "owner") @patch("devx.tools.create_pr.get_repo_name", return_value="repo") def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None: mock_create.return_value = {"number": 1} runner = CliRunner() result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text") assert result.exit_code == 0 mock_create.assert_called_once() assert mock_create.call_args.args[2] == "PR body text" @patch("devx.tools.create_pr.REPO_OWNER", "") @patch("devx.tools.create_pr.get_repo_name", return_value="repo") def test_missing_owner(self, mock_repo: MagicMock) -> None: runner = CliRunner() result = runner.invoke(cli, ["--branch", "DEVX-42-fix"]) assert result.exit_code != 0 assert "owner" in result.output.lower() @patch("devx.tools.create_pr.create_pr") @patch("devx.tools.create_pr.get_repo_name", return_value="repo") def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None: mock_create.return_value = {"number": 1} runner = CliRunner() result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"]) assert result.exit_code == 0 mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo") @patch("devx.tools.create_pr.subprocess.run") @patch("devx.tools.create_pr.REPO_OWNER", "owner") @patch("devx.tools.create_pr.get_repo_name", return_value="repo") def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128) runner = CliRunner() result = runner.invoke(cli, []) assert result.exit_code != 0 assert "Could not detect" in result.output