"""Tests for `muse hub` CLI commands — connect, status, disconnect, ping. All network calls are mocked — no real HTTP traffic occurs. The identity store is isolated per test using a tmp_path override. """ from __future__ import annotations import io import json import pathlib import unittest.mock import urllib.error import urllib.request import urllib.response import pytest from tests.cli_test_helper import CliRunner from muse._version import __version__ cli = None # argparse migration — CliRunner ignores this arg from muse.cli.commands.hub.connection import _hub_hostname, _normalise_url, _ping_hub from muse.cli.config import get_hub_url, set_hub_url from muse.core.identity import IdentityEntry, save_identity from muse.core.paths import commits_dir, heads_dir, muse_dir, objects_dir, snapshots_dir from muse.core.types import MsgpackDict, fake_id, long_id runner = CliRunner() # --------------------------------------------------------------------------- # Fixture: minimal Muse repo # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Minimal .muse/ repo; hub tests don't need commits.""" heads_dir(tmp_path).mkdir(parents=True, exist_ok=True) objects_dir(tmp_path).mkdir(parents=True, exist_ok=True) commits_dir(tmp_path).mkdir(parents=True, exist_ok=True) snapshots_dir(tmp_path).mkdir(parents=True, exist_ok=True) (muse_dir(tmp_path) / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"}) ) (muse_dir(tmp_path) / "HEAD").write_text("ref: refs/heads/main\n") monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) monkeypatch.chdir(tmp_path) return tmp_path # --------------------------------------------------------------------------- # Unit tests for pure helper functions # --------------------------------------------------------------------------- class TestNormaliseUrl: def test_bare_hostname_gets_https(self) -> None: assert _normalise_url("musehub.ai") == "https://musehub.ai" def test_https_url_unchanged(self) -> None: assert _normalise_url("https://musehub.ai") == "https://musehub.ai" def test_trailing_slash_stripped(self) -> None: assert _normalise_url("https://musehub.ai/") == "https://musehub.ai" def test_http_url_raises(self) -> None: with pytest.raises(ValueError, match="Insecure"): _normalise_url("http://musehub.ai") def test_http_suggests_https(self) -> None: with pytest.raises(ValueError, match="https://"): _normalise_url("http://musehub.ai") def test_whitespace_stripped(self) -> None: assert _normalise_url(" https://musehub.ai ") == "https://musehub.ai" class TestHubHostname: def test_extracts_hostname_from_https_url(self) -> None: assert _hub_hostname("https://musehub.ai/repos/r1") == "musehub.ai" def test_bare_hostname(self) -> None: assert _hub_hostname("musehub.ai") == "musehub.ai" def test_strips_path(self) -> None: assert _hub_hostname("https://musehub.ai/deep/path") == "musehub.ai" def test_preserves_port(self) -> None: assert _hub_hostname("https://musehub.ai:8443") == "musehub.ai:8443" class TestPingHub: def test_2xx_returns_true(self) -> None: mock_resp = unittest.mock.MagicMock() mock_resp.status = 200 mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp): ok, msg = _ping_hub("https://musehub.ai") assert ok is True assert "200" in msg def test_5xx_returns_false(self) -> None: mock_resp = unittest.mock.MagicMock() mock_resp.status = 503 mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp): ok, msg = _ping_hub("https://musehub.ai") assert ok is False def test_http_error_returns_false(self) -> None: err = urllib.error.HTTPError("https://musehub.ai/health", 401, "Unauthorized", {}, io.BytesIO(b"Unauthorized")) with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err): ok, msg = _ping_hub("https://musehub.ai") assert ok is False assert "401" in msg def test_url_error_returns_false(self) -> None: err = urllib.error.URLError("name resolution failure") with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err): ok, msg = _ping_hub("https://musehub.ai") assert ok is False def test_timeout_error_returns_false(self) -> None: with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=TimeoutError()): ok, msg = _ping_hub("https://musehub.ai") assert ok is False assert "timed out" in msg def test_os_error_returns_false(self) -> None: with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=OSError("network down")): ok, msg = _ping_hub("https://musehub.ai") assert ok is False def test_health_endpoint_used(self) -> None: calls: list[str] = [] def _fake_open(req: urllib.request.Request, timeout: int = 0) -> urllib.response.addinfourl: calls.append(req.full_url) raise urllib.error.URLError("stop") with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=_fake_open): _ping_hub("https://musehub.ai") assert calls and calls[0] == "https://musehub.ai/health" # --------------------------------------------------------------------------- # hub connect # --------------------------------------------------------------------------- class TestHubConnect: def test_connect_bare_hostname(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "musehub.ai"]) assert result.exit_code == 0 assert "Connected" in result.stderr def test_connect_stores_https_url(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["hub", "connect", "musehub.ai"]) stored = get_hub_url(repo) assert stored == "https://musehub.ai" def test_connect_https_url_directly(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code == 0 assert get_hub_url(repo) == "https://musehub.ai" def test_connect_http_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"]) assert result.exit_code != 0 assert "Insecure" in result.stderr or "rejected" in result.stderr def test_connect_warns_on_hub_switch(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["hub", "connect", "https://hub1.example.com"]) result = runner.invoke(cli, ["hub", "connect", "https://hub2.example.com"]) assert result.exit_code == 0 assert "hub1.example.com" in result.stderr or "Switching" in result.stderr def test_connect_shows_identity_if_already_logged_in(self, repo: pathlib.Path) -> None: entry: IdentityEntry = {"type": "human", "handle": "Alice"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code == 0 assert "Alice" in result.stderr or "human" in result.stderr def test_connect_prompts_login_when_no_identity(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code == 0 assert "muse auth" in result.stderr def test_connect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # hub status # --------------------------------------------------------------------------- class TestHubStatus: def _setup_hub(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "status"]) assert result.exit_code != 0 def test_hub_url_shown(self, repo: pathlib.Path) -> None: self._setup_hub(repo) result = runner.invoke(cli, ["hub", "status"]) assert result.exit_code == 0 assert "musehub.ai" in result.stderr def test_not_authenticated_shown(self, repo: pathlib.Path) -> None: self._setup_hub(repo) result = runner.invoke(cli, ["hub", "status"]) assert "not authenticated" in result.stderr or "auth" in result.stderr def test_identity_fields_shown_when_logged_in(self, repo: pathlib.Path) -> None: self._setup_hub(repo) entry: IdentityEntry = {"type": "agent", "handle": "bot", "fingerprint": "agt_001"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["hub", "status"]) assert "agent" in result.stderr assert "bot" in result.stderr def test_json_output_structure(self, repo: pathlib.Path) -> None: self._setup_hub(repo) result = runner.invoke(cli, ["hub", "status", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert "hub_url" in data assert "hostname" in data assert "authenticated" in data def test_json_output_with_identity(self, repo: pathlib.Path) -> None: self._setup_hub(repo) entry: IdentityEntry = {"type": "human", "handle": "Alice", "fingerprint": "usr_1"} save_identity("https://musehub.ai", entry) result = runner.invoke(cli, ["hub", "status", "--json"]) data = json.loads(result.output) assert data["authenticated"] is True assert data["identity_type"] == "human" assert data["identity_name"] == "Alice" # --------------------------------------------------------------------------- # hub disconnect # --------------------------------------------------------------------------- class TestHubDisconnect: def test_disconnect_clears_hub_url(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["hub", "disconnect"]) assert result.exit_code == 0 assert get_hub_url(repo) is None def test_disconnect_shows_hostname(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["hub", "disconnect"]) assert "musehub.ai" in result.stderr def test_disconnect_nothing_to_do(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "disconnect"]) assert result.exit_code == 0 assert "nothing" in result.stderr.lower() or "No hub" in result.stderr def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None: """Credentials in identity.toml must survive hub disconnect.""" set_hub_url("https://musehub.ai", repo) entry: IdentityEntry = {"type": "human", "handle": "alice"} save_identity("https://musehub.ai", entry) runner.invoke(cli, ["hub", "disconnect"]) from muse.core.identity import load_identity assert load_identity("https://musehub.ai") is not None def test_disconnect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["hub", "disconnect"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # hub ping # --------------------------------------------------------------------------- class TestHubPing: def _setup_hub(self, repo: pathlib.Path) -> None: set_hub_url("https://musehub.ai", repo) def test_ping_success(self, repo: pathlib.Path) -> None: self._setup_hub(repo) mock_resp = unittest.mock.MagicMock() mock_resp.status = 200 mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False) with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp): result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code == 0 assert "200" in result.stderr or "OK" in result.stderr.upper() def test_ping_failure_exits_nonzero(self, repo: pathlib.Path) -> None: self._setup_hub(repo) err = urllib.error.URLError("no route to host") with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err): result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code != 0 def test_ping_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code != 0 def test_ping_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["hub", "ping"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # _enrich_hub_url_from_remote # --------------------------------------------------------------------------- class TestEnrichHubUrlFromRemote: """_enrich_hub_url_from_remote appends owner/slug from a matching remote.""" def _write_remote(self, repo: pathlib.Path, name: str, url: str) -> None: from muse.cli.config import set_remote set_remote(name, url, repo) def test_enriches_bare_hub_url_with_owner_slug(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub._core import _enrich_hub_url_from_remote self._write_remote(repo, "local", "https://localhost:1337/gabriel/muse") result = _enrich_hub_url_from_remote("https://localhost:1337") assert result == "https://localhost:1337/gabriel/muse" def test_leaves_already_enriched_url_unchanged(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub._core import _enrich_hub_url_from_remote self._write_remote(repo, "local", "https://localhost:1337/gabriel/muse") result = _enrich_hub_url_from_remote("https://localhost:1337/gabriel/muse") assert result == "https://localhost:1337/gabriel/muse" def test_returns_bare_url_when_no_matching_remote(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub._core import _enrich_hub_url_from_remote # remote is on a different host — no match self._write_remote(repo, "staging", "http://otherhost:10003/gabriel/muse") result = _enrich_hub_url_from_remote("https://localhost:1337") assert result == "https://localhost:1337" def test_returns_bare_url_when_no_remotes_configured(self, repo: pathlib.Path) -> None: from muse.cli.commands.hub._core import _enrich_hub_url_from_remote result = _enrich_hub_url_from_remote("https://localhost:1337") assert result == "https://localhost:1337" def test_returns_bare_url_outside_repo( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) from muse.cli.commands.hub._core import _enrich_hub_url_from_remote result = _enrich_hub_url_from_remote("https://localhost:1337") assert result == "https://localhost:1337" # --------------------------------------------------------------------------- # muse hub issue read (renamed from "get") # --------------------------------------------------------------------------- class TestHubIssueReadCommand: """'muse hub issue read' is the canonical verb — 'get' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_issue_read_subcommand_exists(self, repo: pathlib.Path) -> None: """'read' must be a valid subcommand — exit code must not be 2 (unknown subcommand).""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = {"number": 1, "title": "Test", "state": "open", "body": ""} with unittest.mock.patch( "muse.cli.commands.hub.issues._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "issue", "read", "1"]) # A valid subcommand that fails for other reasons (auth, network) gives != 2 # The key assertion: exit_code 2 means "unrecognised subcommand" — that must not happen. assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}" def test_issue_get_subcommand_removed(self, repo: pathlib.Path) -> None: """'get' must no longer be accepted.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "issue", "get", "1"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub proposal read (renamed from "view") # --------------------------------------------------------------------------- class TestHubProposalReadCommand: """'muse hub proposal read' is the canonical verb — 'view' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_proposal_read_subcommand_exists(self, repo: pathlib.Path) -> None: """'read' must be a valid subcommand — exit code must not be 2.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_proposal = { "proposalId": "abc123", "number": 1, "title": "Test", "state": "open", "headBranch": "feat/x", "baseBranch": "dev", "author": "gabriel", "createdAt": "2026-04-09T00:00:00Z", "body": "", } with unittest.mock.patch( "muse.cli.commands.hub.proposals._hub_api", return_value={"proposals": [mock_proposal]} ): result = runner.invoke(cli, ["hub", "proposal", "read", "abc123"]) assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}" def test_proposal_view_subcommand_removed(self, repo: pathlib.Path) -> None: """'view' must no longer be accepted.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "proposal", "view", "abc123"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub issue update (renamed from "edit") # --------------------------------------------------------------------------- class TestHubIssueUpdateCommand: """'muse hub issue update' is the canonical verb — 'edit' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_issue_update_subcommand_exists(self, repo: pathlib.Path) -> None: """'update' must be a valid subcommand — exit code must not be 2 (unknown subcommand).""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = {"number": 1, "title": "Updated", "state": "open", "body": "new body"} with unittest.mock.patch( "muse.cli.commands.hub.issues._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "issue", "update", "1", "--body", "new body"]) assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}" def test_issue_edit_subcommand_removed(self, repo: pathlib.Path) -> None: """'edit' must no longer be accepted.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "issue", "edit", "1", "--body", "x"]) assert result.exit_code != 0 def _hub_patches(self, calls: list[tuple]) -> "unittest.mock._patch": # type: ignore[name-defined] """Return a context manager that mocks all three hub network helpers.""" import unittest.mock as mock mock_resp: MsgpackDict = {"number": 1, "title": "x", "state": "open"} def _fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kw: str) -> MsgpackDict: calls.append((method, path)) return mock_resp return mock.patch.multiple( "muse.cli.commands.hub", _hub_api=mock.MagicMock(side_effect=_fake_api), _get_hub_and_identity=mock.MagicMock( return_value=("https://localhost:1337", mock.MagicMock()) ), _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"), ) def test_issue_update_status_closed_calls_close_endpoint(self, repo: pathlib.Path) -> None: """--status closed must call the /close endpoint, not PATCH.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") calls: list[tuple] = [] with self._hub_patches(calls): result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "closed"]) assert result.exit_code == 0, result.output assert any("/close" in path for _, path in calls), ( f"--status closed must call the /close endpoint; got calls: {calls}" ) def test_issue_update_status_open_calls_reopen_endpoint(self, repo: pathlib.Path) -> None: """--status open must call the /reopen endpoint, not PATCH.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") calls: list[tuple] = [] with self._hub_patches(calls): result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "open"]) assert result.exit_code == 0, result.output assert any("/reopen" in path for _, path in calls), ( f"--status open must call the /reopen endpoint; got calls: {calls}" ) def test_issue_update_status_invalid_value_rejected(self, repo: pathlib.Path) -> None: """--status must only accept 'open' or 'closed' — argparse rejects anything else.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "pending"]) assert result.exit_code != 0 def test_issue_update_status_and_body_together(self, repo: pathlib.Path) -> None: """--status closed combined with --body must close AND update the body.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") calls: list[tuple] = [] with self._hub_patches(calls): result = runner.invoke( cli, ["hub", "issue", "update", "1", "--status", "closed", "--body", "done"] ) assert result.exit_code == 0, result.output assert any("/close" in p for _, p in calls), "close endpoint not called" assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update" def test_issue_update_assign_calls_assign_endpoint(self, repo: pathlib.Path) -> None: """--assign must POST to the /assign endpoint.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") calls: list[tuple] = [] with self._hub_patches(calls): result = runner.invoke( cli, ["hub", "issue", "update", "1", "--assign", "gabriel"] ) assert result.exit_code == 0, result.output assert any("/assign" in path for _, path in calls), ( f"--assign must call the /assign endpoint; got: {calls}" ) def test_issue_update_assign_and_body_together(self, repo: pathlib.Path) -> None: """--assign combined with --body must PATCH the body AND POST to /assign.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") calls: list[tuple] = [] with self._hub_patches(calls): result = runner.invoke( cli, ["hub", "issue", "update", "1", "--assign", "gabriel", "--body", "impl done"], ) assert result.exit_code == 0, result.output assert any("/assign" in p for _, p in calls), "assign endpoint not called" assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update" def test_issue_update_assign_and_status_together(self, repo: pathlib.Path) -> None: """--assign + --status closed must call /assign AND /close.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") calls: list[tuple] = [] with self._hub_patches(calls): result = runner.invoke( cli, ["hub", "issue", "update", "1", "--assign", "gabriel", "--status", "closed"], ) assert result.exit_code == 0, result.output assert any("/assign" in p for _, p in calls), "assign endpoint not called" assert any("/close" in p for _, p in calls), "close endpoint not called" # --------------------------------------------------------------------------- # muse hub repo update (renamed from "settings") # --------------------------------------------------------------------------- class TestHubRepoUpdateCommand: """'muse hub repo update' is the canonical verb — 'settings' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_repo_update_subcommand_exists(self, repo: pathlib.Path) -> None: """'update' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = {"repoId": "abc", "name": "muse", "owner": "gabriel"} with unittest.mock.patch( "muse.cli.commands.hub.repos._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "repo", "update", "--description", "x"]) assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}" def test_repo_settings_subcommand_removed(self, repo: pathlib.Path) -> None: """'settings' must no longer be accepted.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "settings"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub repo transfer-ownership (renamed from "transfer") # --------------------------------------------------------------------------- class TestHubRepoTransferOwnershipCommand: """'muse hub repo transfer-ownership' is the canonical verb — 'transfer' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_repo_transfer_ownership_subcommand_exists(self, repo: pathlib.Path) -> None: """'transfer-ownership' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = {"repoId": "abc", "name": "muse", "owner": "bob"} with unittest.mock.patch( "muse.cli.commands.hub.repos._hub_api", return_value=mock_resp ): result = runner.invoke(cli, ["hub", "repo", "transfer-ownership", "--new-owner", "bob"]) assert result.exit_code != 2, f"'transfer-ownership' is not a recognised subcommand: {result.output}" def test_repo_transfer_subcommand_removed(self, repo: pathlib.Path) -> None: """'transfer' must no longer be accepted.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "transfer", "--new-owner", "bob"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub collaborator update-permission (renamed from "update") # --------------------------------------------------------------------------- class TestHubCollaboratorUpdatePermissionCommand: """'muse hub collaborator update-permission' is the canonical verb — 'update' is gone.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_collaborator_update_permission_subcommand_exists(self, repo: pathlib.Path) -> None: """'update-permission' must be a valid collaborator subcommand — exit code must not be 2.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = {"handle": "carol", "permission": "admin"} with unittest.mock.patch( "muse.cli.commands.hub.collaborators._hub_api", return_value=mock_resp ): result = runner.invoke( cli, ["hub", "collaborator", "update-permission", "carol", "--permission", "admin"] ) assert result.exit_code != 2, f"'update-permission' is not a recognised subcommand: {result.output}" def test_collaborator_update_subcommand_removed(self, repo: pathlib.Path) -> None: """'update' must no longer be accepted as a collaborator subcommand.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke( cli, ["hub", "collaborator", "update", "carol", "--permission", "admin"] ) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse hub repo list # --------------------------------------------------------------------------- class TestHubRepoListCommand: """'muse hub repo list' lists repos for the authenticated user.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) def test_repo_list_subcommand_exists(self, repo: pathlib.Path) -> None: """'list' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = { "total": 1, "nextCursor": None, "repos": [ { "repoId": "abc", "name": "my-repo", "owner": "gabriel", "slug": "my-repo", "visibility": "public", "description": "Test repo", "tags": [], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "", } ], } with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=mock_resp), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke(cli, ["hub", "repo", "list"]) assert result.exit_code != 2, f"'list' is not a recognised subcommand: {result.output}" def test_repo_list_json_output_structure(self, repo: pathlib.Path) -> None: """--json emits total, next_cursor, and repos list to stdout.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = { "total": 2, "nextCursor": None, "repos": [ { "repoId": "id1", "name": "alpha", "owner": "gabriel", "slug": "alpha", "visibility": "public", "description": "", "tags": [], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "", }, { "repoId": "id2", "name": "beta", "owner": "gabriel", "slug": "beta", "visibility": "private", "description": "private repo", "tags": ["music"], "defaultBranch": "dev", "createdAt": "2026-01-02T00:00:00Z", "pushedAt": "", }, ], } with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=mock_resp), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke(cli, ["hub", "repo", "list", "--json"]) assert result.exit_code == 0, result.output out = json.loads(result.output) assert out["total"] == 2 assert out["next_cursor"] is None assert len(out["repos"]) == 2 slugs = [r["slug"] for r in out["repos"]] assert "alpha" in slugs assert "beta" in slugs def test_repo_list_json_fields_present(self, repo: pathlib.Path) -> None: """Each repo in --json output has all documented fields.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = { "total": 1, "nextCursor": None, "repos": [ { "repoId": "abc", "name": "check-fields", "owner": "gabriel", "slug": "check-fields", "visibility": "public", "description": "desc", "tags": ["a", "b"], "defaultBranch": "main", "createdAt": "2026-01-01T00:00:00Z", "pushedAt": "2026-03-01T00:00:00Z", } ], } with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=mock_resp), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke(cli, ["hub", "repo", "list", "--json"]) assert result.exit_code == 0 repos = json.loads(result.output)["repos"] assert len(repos) == 1 for field in ("repo_id", "name", "owner", "slug", "visibility", "description", "tags", "default_branch", "created_at", "pushed_at"): assert field in repos[0], f"missing field: {field}" def test_repo_list_passes_limit_to_api(self, repo: pathlib.Path) -> None: """--limit is forwarded as a query parameter.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") captured: list[str] = [] def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kwargs: str) -> MsgpackDict: captured.append(path) return {"total": 0, "nextCursor": None, "repos": []} with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(side_effect=fake_api), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): runner.invoke(cli, ["hub", "repo", "list", "--limit", "42", "--json"]) assert captured, "no API call made" assert "limit=42" in captured[0] def test_repo_list_empty_prints_no_repos(self, repo: pathlib.Path) -> None: """Empty repo list exits 0 and does not crash.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") mock_resp = {"total": 0, "nextCursor": None, "repos": []} with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=mock_resp), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke(cli, ["hub", "repo", "list"]) assert result.exit_code == 0 # --------------------------------------------------------------------------- # muse hub repo read # --------------------------------------------------------------------------- class TestHubRepoReadCommand: """'muse hub repo read' fetches metadata for a single repo.""" def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(hub_url, identity) _MOCK_REPO = { "repoId": "abc123", "name": "jazz-standards", "owner": "gabriel", "slug": "jazz-standards", "visibility": "public", "description": "A collection of jazz standards", "tags": ["music", "jazz"], "defaultBranch": "main", "cloneUrl": "https://localhost:1337/gabriel/jazz-standards", "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-02-01T00:00:00Z", "pushedAt": "2026-03-01T00:00:00Z", } def test_repo_read_subcommand_exists(self, repo: pathlib.Path) -> None: """'read' must be a valid repo subcommand — exit code must not be 2.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards"]) assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}" def test_repo_read_json_output_structure(self, repo: pathlib.Path) -> None: """--json emits all documented repo fields to stdout.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke( cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"] ) assert result.exit_code == 0, result.output out = json.loads(result.output) assert out["repo_id"] == "abc123" assert out["slug"] == "jazz-standards" assert out["owner"] == "gabriel" assert out["visibility"] == "public" def test_repo_read_json_fields_present(self, repo: pathlib.Path) -> None: """All documented fields are present in --json output.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke( cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"] ) assert result.exit_code == 0 out = json.loads(result.output) for field in ("repo_id", "name", "owner", "slug", "visibility", "description", "tags", "default_branch", "clone_url", "created_at", "updated_at", "pushed_at"): assert field in out, f"missing field: {field}" def test_repo_read_uses_owner_slug_url(self, repo: pathlib.Path) -> None: """OWNER/SLUG argument resolves via /api/{owner}/{slug} not /api/repos/{id}.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") captured: list[str] = [] def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kwargs: str) -> MsgpackDict: captured.append(path) return self._MOCK_REPO with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(side_effect=fake_api), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]) assert captured, "no API call made" assert "/api/gabriel/jazz-standards" in captured[0] def test_repo_read_tags_preserved(self, repo: pathlib.Path) -> None: """Tags list is preserved intact in JSON output.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", unittest.mock.MagicMock()) ), ): result = runner.invoke( cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"] ) assert result.exit_code == 0 out = json.loads(result.output) assert out["tags"] == ["music", "jazz"] class TestHubRepoDeleteCommand: """Tests for run_repo_delete with the new TARGET argument.""" def test_delete_by_repo_id_calls_correct_endpoint(self) -> None: """run_repo_delete with a repo_id target calls DELETE /api/repos/{repo_id}.""" import argparse import unittest.mock as mock from muse.cli.commands.hub.repos import run_repo_delete repo_id = fake_id("hub-repo-delete-target") with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("https://localhost:1337", mock.MagicMock())): args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True) run_repo_delete(args) calls = [str(c) for c in m_api.call_args_list] assert any(f"/api/repos/{repo_id}" in c for c in calls), ( f"Expected DELETE /api/repos/{repo_id}, got: {calls}" ) def test_delete_by_owner_slug_resolves_then_deletes(self) -> None: """run_repo_delete with OWNER/SLUG fetches repo_id then DELETEs it.""" import argparse import unittest.mock as mock from muse.cli.commands.hub.repos import run_repo_delete resolved_id = fake_id("hub-repo-resolved-id") get_resp = {"repoId": resolved_id, "name": "my-repo", "owner": "gabriel"} with mock.patch("muse.cli.commands.hub._hub_api", side_effect=[get_resp, {}]) as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("https://localhost:1337", mock.MagicMock())): args = argparse.Namespace(target="gabriel/my-repo", yes=True, hub=None, json_output=True) run_repo_delete(args) calls = m_api.call_args_list assert len(calls) == 2 # First: GET to resolve owner/slug assert calls[0].args[2] == "GET" assert "/api/gabriel/my-repo" in calls[0].args[3] # Second: DELETE with resolved repo_id assert calls[1].args[2] == "DELETE" assert f"/api/repos/{resolved_id}" in calls[1].args[3] def test_delete_without_yes_exits_nonzero_and_skips_api(self) -> None: """Without --yes, exits non-zero and never calls the API.""" import argparse import pytest import unittest.mock as mock from muse.cli.commands.hub.repos import run_repo_delete with mock.patch("muse.cli.commands.hub._hub_api") as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("https://localhost:1337", mock.MagicMock())): args = argparse.Namespace(target="gabriel/my-repo", yes=False, hub=None, json_output=False) with pytest.raises(SystemExit) as exc_info: run_repo_delete(args) assert exc_info.value.code != 0 m_api.assert_not_called() def test_delete_no_target_falls_back_to_config_resolution(self) -> None: """When target is None, repo_id is resolved from the current directory config.""" import argparse import unittest.mock as mock from muse.cli.commands.hub.repos import run_repo_delete config_repo_id = "c5f6e7a8-0000-0000-0000-000000000003" with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("https://localhost:1337", mock.MagicMock())), \ mock.patch("muse.cli.commands.hub._resolve_repo_id", return_value=config_repo_id): args = argparse.Namespace(target=None, yes=True, hub=None, json_output=True) run_repo_delete(args) calls = [str(c) for c in m_api.call_args_list] assert any(f"/api/repos/{config_repo_id}" in c for c in calls), ( f"Expected DELETE using config repo_id, got: {calls}" ) def test_delete_json_output_emits_structured_result(self) -> None: """--json flag emits {deleted: true, repo_id: ...} to stdout.""" import argparse import io import json as json_mod import sys import unittest.mock as mock from muse.cli.commands.hub.repos import run_repo_delete repo_id = fake_id("hub-repo-delete-json-output") with mock.patch("muse.cli.commands.hub._hub_api", return_value={}), \ mock.patch("muse.cli.commands.hub._get_hub_and_identity", return_value=("https://localhost:1337", mock.MagicMock())): args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True) captured = io.StringIO() with mock.patch("sys.stdout", captured): run_repo_delete(args) output = json_mod.loads(captured.getvalue()) assert output["deleted"] is True assert output["repo_id"] == repo_id # --------------------------------------------------------------------------- # Agent ergonomics — absorb git/GitHub muscle-memory flags # --------------------------------------------------------------------------- def _mock_create_resp() -> MsgpackDict: return { "repoId": "abc-123", "name": "my-repo", "owner": "gabriel", "slug": "my-repo", "visibility": "public", "description": "", "cloneUrl": "https://staging.musehub.ai/gabriel/my-repo", "tags": [], "createdAt": "2026-04-22T00:00:00Z", } class TestRepoCreateVisibilityAlias: """``hub repo create --visibility public|private`` must work as an alias for the canonical ``--private`` boolean flag. Agents trained on GitHub CLI reach for ``--visibility`` reflexively. Rejecting it with an argparse "unrecognized arguments" error wastes a round-trip and forces the agent to re-read docs. Absorbing the flag silently maps it to the right internal value. """ def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint=long_id("a" * 64), ) save_identity(hub_url, identity) def test_visibility_public_is_accepted(self, repo: pathlib.Path) -> None: """``--visibility public`` must not be rejected by the parser.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(return_value=_mock_create_resp()), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", {"handle": "gabriel"}) ), ): result = runner.invoke( cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--json"] ) assert result.exit_code != 2, ( "--visibility public must not produce 'unrecognized arguments'; " f"got: {result.output}" ) assert result.exit_code == 0, result.output def test_visibility_private_maps_to_private(self, repo: pathlib.Path) -> None: """``--visibility private`` must create a private repo (same as ``--private``).""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") captured_payload: list[MsgpackDict] = [] def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: MsgpackDict | None = None, **kw: str) -> MsgpackDict: if body: captured_payload.append(body) return _mock_create_resp() with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(side_effect=fake_api), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", {"handle": "gabriel"}) ), ): result = runner.invoke( cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "private", "--json"] ) assert result.exit_code == 0, result.output assert captured_payload, "no API call was made" assert captured_payload[0]["visibility"] == "private", ( "--visibility private must send visibility=private to the API" ) def test_visibility_public_sends_public(self, repo: pathlib.Path) -> None: """``--visibility public`` must send visibility=public to the API.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") captured_payload: list[MsgpackDict] = [] def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: MsgpackDict | None = None, **kw: str) -> MsgpackDict: if body: captured_payload.append(body) return _mock_create_resp() with unittest.mock.patch.multiple( "muse.cli.commands.hub", _hub_api=unittest.mock.MagicMock(side_effect=fake_api), _get_hub_and_identity=unittest.mock.MagicMock( return_value=("https://localhost:1337", {"handle": "gabriel"}) ), ): result = runner.invoke( cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--json"] ) assert result.exit_code == 0, result.output assert captured_payload[0]["visibility"] == "public" def test_visibility_invalid_value_exits_nonzero(self, repo: pathlib.Path) -> None: """``--visibility protected`` (invalid) must fail with a clear error.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke( cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "protected"] ) assert result.exit_code != 0, "invalid --visibility value must not succeed" combined = (result.output or "") + (result.stderr or "") assert "protected" in combined or "public" in combined or "private" in combined, ( "error must mention the invalid value or valid choices" ) def test_visibility_conflicts_with_private_flag(self, repo: pathlib.Path) -> None: """``--visibility public --private`` is contradictory and must be rejected.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke( cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--private"] ) assert result.exit_code != 0, ( "--visibility public combined with --private is contradictory and must fail" ) class TestRepoListOwnerFlag: """``hub repo list --owner`` must give an actionable error, not a generic argparse rejection. Agents trained on GitHub CLI reach for ``--owner gabriel`` reflexively. The correct Muse pattern is to fetch all repos and filter in Python. The error must explain this and show the exact filter command. """ def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None: set_hub_url(hub_url, repo) identity = IdentityEntry( type="human", handle="gabriel", key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"), algorithm="ed25519", fingerprint=long_id("a" * 64), ) save_identity(hub_url, identity) def test_owner_flag_is_accepted_by_parser(self, repo: pathlib.Path) -> None: """``--owner`` must not produce argparse's generic 'unrecognized arguments' error.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"]) assert result.exit_code != 2, ( "--owner must not produce exit code 2 (unrecognized argument); " f"got output: {result.output}" ) def test_owner_flag_exits_with_helpful_error(self, repo: pathlib.Path) -> None: """``--owner`` must exit non-zero with a message explaining the filter pattern.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"]) assert result.exit_code != 0, "--owner must exit non-zero (it is not a real filter)" combined = (result.output or "") + (result.stderr or "") assert "owner" in combined.lower(), "error must mention 'owner'" def test_owner_error_suggests_pipe_pattern(self, repo: pathlib.Path) -> None: """The error message must show the python-pipe filter pattern.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"]) combined = (result.output or "") + (result.stderr or "") assert "python" in combined.lower() or "json" in combined.lower(), ( "error must suggest filtering via --json | python3" ) def test_owner_error_names_the_owner_value(self, repo: pathlib.Path) -> None: """The error must echo back the owner value so the agent knows it was received.""" self._setup_auth(repo, "https://localhost:1337/gabriel/muse") result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"]) combined = (result.output or "") + (result.stderr or "") assert "gabriel" in combined, "error must echo back the owner value" # --------------------------------------------------------------------------- # muse hub proposal update # --------------------------------------------------------------------------- type _PropData = dict[str, str | int | float | bool | None] _KwVal = str | int | float | bool | None class TestHubProposalUpdateCommand: """'muse hub proposal update' — partial PATCH for title/body/type/strategy.""" _HUB = "https://localhost:1337/gabriel/muse" def _setup_auth(self, repo: pathlib.Path) -> None: set_hub_url(self._HUB, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(self._HUB, identity) def _mock_proposal(self, **overrides: _KwVal) -> _PropData: base: _PropData = { "proposalId": "sha256:" + "a" * 64, "number": 1, "title": "Original title", "body": "Original body.", "state": "open", "proposalType": "state_merge", "mergeStrategy": "state_overlay", "fromBranch": "feat/x", "toBranch": "dev", "author": "gabriel", "createdAt": "2026-05-01T00:00:00Z", } return {**base, **overrides} def _patches(self, api_return: _PropData, calls: list[tuple[str, ...]] | None = None) -> "unittest.mock._patch": # type: ignore[name-defined] import unittest.mock as mock def _fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kw: _KwVal) -> _PropData: if calls is not None: kw_body = kw.get("body", {}) calls.append((method, path, kw_body)) return api_return return mock.patch.multiple( "muse.cli.commands.hub.proposals", _hub_api=mock.MagicMock(side_effect=_fake_api), _get_hub_and_identity=mock.MagicMock( return_value=(self._HUB.rsplit("/", 2)[0], mock.MagicMock()) ), _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"), _resolve_proposal_id=mock.MagicMock(side_effect=lambda *a: a[3]), ) # ── T11.1 — subcommand exists ───────────────────────────────────────────── def test_update_subcommand_exists(self, repo: pathlib.Path) -> None: """'update' must be a valid subcommand — exit code must not be 2.""" self._setup_auth(repo) with self._patches(self._mock_proposal()): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--title", "New title"] ) assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}" # ── T11.2 — update title ────────────────────────────────────────────────── def test_update_title_calls_patch(self, repo: pathlib.Path) -> None: """--title must issue a PATCH request with the new title.""" self._setup_auth(repo) calls: list[tuple[str, ...]] = [] updated = self._mock_proposal(title="Shiny new title") with self._patches(updated, calls): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--title", "Shiny new title"] ) assert result.exit_code == 0, result.output methods = [m for m, _, _ in calls] assert "PATCH" in methods, f"PATCH not called; calls: {calls}" bodies = [b for _, _, b in calls if isinstance(b, dict)] assert any("title" in b for b in bodies), f"body missing 'title'; calls: {calls}" # ── T11.3 — update body ─────────────────────────────────────────────────── def test_update_body_calls_patch(self, repo: pathlib.Path) -> None: """--body must issue a PATCH request with the new body.""" self._setup_auth(repo) calls: list[tuple[str, ...]] = [] updated = self._mock_proposal(body="Updated description.") with self._patches(updated, calls): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--body", "Updated description."] ) assert result.exit_code == 0, result.output bodies = [b for _, _, b in calls if isinstance(b, dict)] assert any("body" in b for b in bodies), f"body missing 'body' key; calls: {calls}" # ── T11.4 — update type ─────────────────────────────────────────────────── def test_update_type_calls_patch(self, repo: pathlib.Path) -> None: """--type must issue a PATCH with proposal_type.""" self._setup_auth(repo) calls: list[tuple[str, ...]] = [] updated = self._mock_proposal(proposalType="canonical_release") with self._patches(updated, calls): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--type", "canonical_release"] ) assert result.exit_code == 0, result.output bodies = [b for _, _, b in calls if isinstance(b, dict)] assert any("proposal_type" in b for b in bodies), f"body missing 'proposal_type'; calls: {calls}" # ── T11.5 — update strategy ─────────────────────────────────────────────── def test_update_strategy_calls_patch(self, repo: pathlib.Path) -> None: """--strategy must issue a PATCH with merge_strategy.""" self._setup_auth(repo) calls: list[tuple[str, ...]] = [] updated = self._mock_proposal(mergeStrategy="state_rebase") with self._patches(updated, calls): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--strategy", "state_rebase"] ) assert result.exit_code == 0, result.output bodies = [b for _, _, b in calls if isinstance(b, dict)] assert any("merge_strategy" in b for b in bodies), f"body missing 'merge_strategy'; calls: {calls}" # ── T11.6 — no flags → nonzero exit ────────────────────────────────────── def test_no_flags_exits_nonzero(self, repo: pathlib.Path) -> None: """Supplying no update flags must exit nonzero — nothing to patch.""" self._setup_auth(repo) with self._patches(self._mock_proposal()): result = runner.invoke(cli, ["hub", "proposal", "update", "abc123"]) assert result.exit_code != 0, "expected nonzero exit when no update flags supplied" # ── T11.7 — json output ─────────────────────────────────────────────────── def test_json_output_contains_proposal_id(self, repo: pathlib.Path) -> None: """--json must emit a JSON object containing the updated proposal data.""" self._setup_auth(repo) updated = self._mock_proposal(title="JSON title") with self._patches(updated): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--title", "JSON title", "--json"] ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert "proposalId" in data or "proposal_id" in data, ( f"JSON output must contain proposalId; got keys: {list(data.keys())}" ) # ── T11.8 — body-file ───────────────────────────────────────────────────── def test_body_file_reads_from_disk(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None: """--body-file must read the body from disk and send it in the PATCH.""" self._setup_auth(repo) body_path = tmp_path / "body.md" body_path.write_text("Body from file.") calls: list[tuple[str, ...]] = [] updated = self._mock_proposal(body="Body from file.") with self._patches(updated, calls): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--body-file", str(body_path)] ) assert result.exit_code == 0, result.output bodies = [b for _, _, b in calls if isinstance(b, dict)] assert any(b.get("body") == "Body from file." for b in bodies), ( f"PATCH body must contain file content; got: {bodies}" ) # ── T11.9 — api error propagates ───────────────────────────────────────── def test_api_error_exits_code_3(self, repo: pathlib.Path) -> None: """An API / network error must exit with code 3.""" self._setup_auth(repo) import unittest.mock as mock def _raise(*a: str, **kw: _KwVal) -> None: raise RuntimeError("network failure") with mock.patch.multiple( "muse.cli.commands.hub.proposals", _hub_api=mock.MagicMock(side_effect=_raise), _get_hub_and_identity=mock.MagicMock( return_value=(self._HUB.rsplit("/", 2)[0], mock.MagicMock()) ), _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"), _resolve_proposal_id=mock.MagicMock(side_effect=lambda *a: a[3]), ): result = runner.invoke( cli, ["hub", "proposal", "update", "abc123", "--title", "X"] ) assert result.exit_code == 3, f"expected exit 3 on API error; got {result.exit_code}: {result.output}" # --------------------------------------------------------------------------- # muse hub proposal close # --------------------------------------------------------------------------- class TestHubProposalCloseCommand: """'muse hub proposal close' — POST .../close endpoint.""" _HUB = "https://localhost:1337/gabriel/muse" def _setup_auth(self, repo: pathlib.Path) -> None: set_hub_url(self._HUB, repo) identity = IdentityEntry( type="human", handle="gabriel", algorithm="ed25519", fingerprint="deadbeef", ) save_identity(self._HUB, identity) def _mock_proposal(self, **overrides: _KwVal) -> _PropData: base: _PropData = { "proposalId": "sha256:" + "a" * 64, "number": 1, "title": "Close me", "state": "closed", "fromBranch": "feat/x", "toBranch": "dev", "author": "gabriel", "createdAt": "2026-05-09T00:00:00Z", } return {**base, **overrides} def _patches(self, api_return: _PropData, calls: list[tuple[str, ...]] | None = None) -> "unittest.mock._patch": # type: ignore[name-defined] import unittest.mock as mock def _fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kw: _KwVal) -> _PropData: if calls is not None: calls.append((method, path)) return api_return return mock.patch.multiple( "muse.cli.commands.hub.proposals", _hub_api=mock.MagicMock(side_effect=_fake_api), _get_hub_and_identity=mock.MagicMock( return_value=(self._HUB.rsplit("/", 2)[0], mock.MagicMock()) ), _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"), _resolve_proposal_id=mock.MagicMock(side_effect=lambda *a: a[3]), ) def test_close_subcommand_exists(self, repo: pathlib.Path) -> None: """'close' must be a valid subcommand — exit code must not be 2.""" self._setup_auth(repo) with self._patches(self._mock_proposal()): result = runner.invoke(cli, ["hub", "proposal", "close", "abc123"]) assert result.exit_code != 2, f"'close' is not a recognised subcommand: {result.output}" def test_close_calls_post_to_close_endpoint(self, repo: pathlib.Path) -> None: """'close' must issue POST to .../proposals/{id}/close.""" self._setup_auth(repo) calls: list[tuple[str, ...]] = [] with self._patches(self._mock_proposal(), calls): result = runner.invoke(cli, ["hub", "proposal", "close", "abc123"]) assert result.exit_code == 0, result.output assert any("POST" == m and "/close" in p for m, p in calls), ( f"Expected POST to /close endpoint; got calls: {calls}" ) def test_close_json_output_contains_proposal_id(self, repo: pathlib.Path) -> None: """--json must emit JSON with proposalId and state=closed.""" self._setup_auth(repo) with self._patches(self._mock_proposal()): result = runner.invoke(cli, ["hub", "proposal", "close", "abc123", "--json"]) assert result.exit_code == 0, result.output data = json.loads(result.output) assert "proposalId" in data or "proposal_id" in data, ( f"JSON output must contain proposalId; got keys: {list(data.keys())}" ) assert data.get("state") == "closed", f"state must be 'closed'; got: {data.get('state')}" def test_close_text_output_confirms_closed(self, repo: pathlib.Path) -> None: """Text output (stderr) must confirm the proposal was closed.""" self._setup_auth(repo) with self._patches(self._mock_proposal()): result = runner.invoke(cli, ["hub", "proposal", "close", "abc123"]) assert result.exit_code == 0, result.output combined = result.output + (result.stderr if hasattr(result, "stderr") else "") assert "closed" in combined.lower(), ( f"Expected 'closed' confirmation in output; got: {combined!r}" ) def test_close_requires_proposal_id(self, repo: pathlib.Path) -> None: """'close' without a proposal_id must exit with code 2.""" self._setup_auth(repo) result = runner.invoke(cli, ["hub", "proposal", "close"]) assert result.exit_code == 2, ( f"Expected exit 2 (argparse error) without proposal_id; got {result.exit_code}" ) def test_close_hub_flag_accepted(self, repo: pathlib.Path) -> None: """--hub override must be accepted without error.""" self._setup_auth(repo) with self._patches(self._mock_proposal()): result = runner.invoke( cli, ["hub", "proposal", "close", "abc123", "--hub", "https://staging.musehub.ai/gabriel/muse"], ) assert result.exit_code != 2, f"--hub flag rejected: {result.output}"