"""Unit tests for scripts/gitea_cli.py.""" from __future__ import annotations import json from unittest.mock import MagicMock, patch import pytest from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login class TestExtractIssueNumber: def test_extract_from_created_issue(self) -> None: assert _extract_issue_number("Created issue #42: Bug title") == 42 def test_extract_no_hash(self) -> None: assert _extract_issue_number("No issue number here") == 0 def test_extract_multiple_hashes(self) -> None: assert _extract_issue_number("Issue #5 and PR #10") == 5 def test_extract_with_colon(self) -> None: assert _extract_issue_number("Created issue #7: title") == 7 def test_extract_invalid_number(self) -> None: assert _extract_issue_number("Issue #abc: title") == 0 class TestExtractPrNumber: def test_extract_from_created_pr(self) -> None: assert _extract_pr_number("Created PR #128: Feature") == 128 def test_extract_no_number(self) -> None: assert _extract_pr_number("No PR number") == 0 class TestTeaCLIInit: def test_auto_detect_tea(self) -> None: with patch("shutil.which", return_value="/usr/bin/tea"): cli = TeaCLI() assert cli._tea == "/usr/bin/tea" def test_explicit_tea_bin(self) -> None: cli = TeaCLI(tea_bin="/custom/tea") assert cli._tea == "/custom/tea" def test_fallback_to_tea(self) -> None: with patch("shutil.which", return_value=None): cli = TeaCLI() assert cli._tea == "tea" def test_with_repo(self) -> None: cli = TeaCLI(repo="owner/repo") assert cli._repo == "owner/repo" class TestTeaCLIRun: def test_run_success_json(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="") with patch("subprocess.run", return_value=mock_result): output = cli._run(["labels", "list"]) assert output == '[{"id": 1}]' def test_run_success_raw(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="") with patch("subprocess.run", return_value=mock_result): output = cli._run_raw(["issues", "create"]) assert output == "Created issue #42" def test_run_failure_raises(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=1, stdout="", stderr="auth error") with patch("subprocess.run", return_value=mock_result): with pytest.raises(TeaCLIError, match="auth error"): cli._run(["labels", "list"]) def test_run_tea_not_found_raises_tea_error(self) -> None: cli = TeaCLI(tea_bin="tea") with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")): with pytest.raises(TeaCLIError, match="tea binary not found"): cli._run(["labels", "list"]) def test_run_includes_json_flag(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="[]", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli._run(["labels", "list"]) cmd = mock_run.call_args[0][0] assert "--output" in cmd assert "json" in cmd def test_run_raw_no_json_flag(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="ok", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli._run_raw(["whoami"]) cmd = mock_run.call_args[0][0] assert "--output" not in cmd class TestRepoArg: def test_with_repo_arg(self) -> None: cli = TeaCLI(repo="owner/repo") assert cli._repo_arg() == ["--repo", "owner/repo"] def test_with_explicit_repo(self) -> None: cli = TeaCLI() assert cli._repo_arg("other/repo") == ["--repo", "other/repo"] def test_without_repo(self) -> None: cli = TeaCLI() assert cli._repo_arg() == [] def test_explicit_overrides_default(self) -> None: cli = TeaCLI(repo="default/repo") assert cli._repo_arg("override/repo") == ["--repo", "override/repo"] class TestCreateIssue: def test_create_issue_basic(self) -> None: cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo") mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="") with patch("subprocess.run", return_value=mock_result): issue = cli.create_issue("owner/repo", title="Bug", body="Description") assert issue["index"] == 42 assert issue["title"] == "Bug" def test_create_issue_with_labels(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="") with patch("subprocess.run", return_value=mock_result): issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"]) assert issue["index"] == 5 class TestListLabels: def test_list_labels_with_data(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}]) mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="") with patch("subprocess.run", return_value=mock_result): labels = cli.list_labels("owner/repo") assert len(labels) == 2 assert labels[0]["name"] == "bug" def test_list_labels_empty(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="", stderr="") with patch("subprocess.run", return_value=mock_result): labels = cli.list_labels("owner/repo") assert labels == [] class TestCreateLabel: def test_create_label_full(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Label created", stderr="") with patch("subprocess.run", return_value=mock_result): label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug") assert label["name"] == "bug" assert label["color"] == "ff0000" def test_create_label_name_only(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Label created", stderr="") with patch("subprocess.run", return_value=mock_result): label = cli.create_label("owner/repo", name="wip") assert label["name"] == "wip" assert label["color"] == "" class TestAddLabel: def test_add_label_single(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="ok", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.add_label("owner/repo", 42, ["ready-to-merge"]) cmd = mock_run.call_args[0][0] assert "--add-labels" in cmd assert "ready-to-merge" in cmd assert "42" in cmd def test_add_label_multiple(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="ok", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.add_label("owner/repo", 42, ["bug", "urgent"]) cmd = mock_run.call_args[0][0] assert "--add-labels" in cmd def test_add_label_empty_list(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") with patch("subprocess.run") as mock_run: cli.add_label("owner/repo", 42, []) mock_run.assert_not_called() class TestCreatePR: def test_create_pr_basic(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="") with patch("subprocess.run", return_value=mock_result): pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master") assert pr["index"] == 128 def test_create_pr_with_body(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description") cmd = mock_run.call_args[0][0] assert "--body" in cmd assert "Description" in cmd class TestMergePR: def test_merge_pr_squash(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Merged", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.merge_pr("owner/repo", 42, style="squash") cmd = mock_run.call_args[0][0] assert "--style" in cmd assert "squash" in cmd assert "42" in cmd def test_merge_pr_default_style(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Merged", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.merge_pr("owner/repo", 42) cmd = mock_run.call_args[0][0] assert "squash" in cmd class TestReviewPR: def test_review_approve(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM") cmd = mock_run.call_args[0][0] assert "--approve" in cmd assert "--comment" in cmd def test_review_reject(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work") cmd = mock_run.call_args[0][0] assert "--reject" in cmd def test_review_comment(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.review_pr("owner/repo", 42, event="COMMENT", body="Note") cmd = mock_run.call_args[0][0] assert "--approve" not in cmd assert "--reject" not in cmd assert "--comment" in cmd def test_review_no_body(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.review_pr("owner/repo", 42, event="COMMENT") cmd = mock_run.call_args[0][0] assert "--comment" not in cmd class TestCreateRelease: def test_create_release_full(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Release created", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: release = cli.create_release( "owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes", target="master", ) cmd = mock_run.call_args[0][0] assert "v1.0.0" in cmd assert "--title" in cmd assert "--note" in cmd assert "--target" in cmd assert release["tag"] == "v1.0.0" def test_create_release_draft(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Release created", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.create_release("owner/repo", tag="v0.1.0", draft=True) cmd = mock_run.call_args[0][0] assert "--draft" in cmd def test_create_release_prerelease(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Release created", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True) cmd = mock_run.call_args[0][0] assert "--prerelease" in cmd def test_create_release_minimal(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="Release created", stderr="") with patch("subprocess.run", return_value=mock_result) as mock_run: release = cli.create_release("owner/repo", tag="v1.0.0") cmd = mock_run.call_args[0][0] assert "--title" not in cmd assert "--note" not in cmd assert release["tag"] == "v1.0.0" class TestListReleases: def test_list_releases_with_data(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}]) mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="") with patch("subprocess.run", return_value=mock_result): releases = cli.list_releases("owner/repo") assert len(releases) == 2 def test_list_releases_empty(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="", stderr="") with patch("subprocess.run", return_value=mock_result): releases = cli.list_releases("owner/repo") assert releases == [] class TestListBranches: def test_list_branches_with_data(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") branches_json = json.dumps([{"name": "master"}, {"name": "develop"}]) mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="") with patch("subprocess.run", return_value=mock_result): branches = cli.list_branches("owner/repo") assert len(branches) == 2 def test_list_branches_empty(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="", stderr="") with patch("subprocess.run", return_value=mock_result): branches = cli.list_branches("owner/repo") assert branches == [] class TestWhoami: def test_whoami(self) -> None: cli = TeaCLI(tea_bin="/fake/tea") mock_result = MagicMock(returncode=0, stdout="testuser", stderr="") with patch("subprocess.run", return_value=mock_result): assert cli.whoami() == "testuser" class TestConfigureTeaLogin: @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True) @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") def test_no_token_skips(self, mock_which: MagicMock) -> None: """configure_tea_login with no token prints skip message and returns.""" configure_tea_login() @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) @patch("devx.gitea_cli.shutil.which", return_value=None) def test_no_tea_skips(self, mock_which: MagicMock) -> None: """configure_tea_login with no tea binary prints skip message and returns.""" configure_tea_login() @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.subprocess.run") def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: """configure_tea_login adds login when not already configured.""" mock_list = MagicMock(returncode=0, stdout="") mock_subprocess.return_value = mock_list configure_tea_login() assert mock_subprocess.call_count >= 2 # login list + login add + login default @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}) @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.subprocess.run") def test_skips_when_already_configured(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None: """configure_tea_login skips if login already exists.""" mock_list = MagicMock(returncode=0, stdout="devx https://git.example.com") mock_subprocess.return_value = mock_list configure_tea_login() assert mock_subprocess.call_count == 1 # only login list, no add