gabriel / muse public
test_cli_hub.py python
1,297 lines 56.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for `muse hub` CLI commands — connect, status, disconnect, ping.
2
3 All network calls are mocked — no real HTTP traffic occurs. The identity
4 store is isolated per test using a tmp_path override.
5 """
6
7 from __future__ import annotations
8
9 import io
10 import json
11 import pathlib
12 import unittest.mock
13 import urllib.error
14 import urllib.request
15 import urllib.response
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19
20 from muse._version import __version__
21 cli = None # argparse migration — CliRunner ignores this arg
22 from muse.cli.commands.hub.connection import _hub_hostname, _normalise_url, _ping_hub
23 from muse.cli.config import get_hub_url, set_hub_url
24 from muse.core.identity import IdentityEntry, save_identity
25 from muse.core._types import MsgpackDict
26
27 runner = CliRunner()
28
29
30 # ---------------------------------------------------------------------------
31 # Fixture: minimal Muse repo
32 # ---------------------------------------------------------------------------
33
34
35 @pytest.fixture()
36 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
37 """Minimal .muse/ repo; hub tests don't need commits."""
38 muse_dir = tmp_path / ".muse"
39 (muse_dir / "refs" / "heads").mkdir(parents=True)
40 (muse_dir / "objects").mkdir()
41 (muse_dir / "commits").mkdir()
42 (muse_dir / "snapshots").mkdir()
43 (muse_dir / "repo.json").write_text(
44 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"})
45 )
46 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
47 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
48 monkeypatch.chdir(tmp_path)
49 # Redirect the identity store to tmp_path so tests never touch ~/.muse/
50 fake_identity_dir = tmp_path / "fake_home" / ".muse"
51 fake_identity_dir.mkdir(parents=True)
52 fake_identity_file = fake_identity_dir / "identity.toml"
53 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_identity_dir)
54 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_identity_file)
55 return tmp_path
56
57
58 # ---------------------------------------------------------------------------
59 # Unit tests for pure helper functions
60 # ---------------------------------------------------------------------------
61
62
63 class TestNormaliseUrl:
64 def test_bare_hostname_gets_https(self) -> None:
65 assert _normalise_url("musehub.ai") == "https://musehub.ai"
66
67 def test_https_url_unchanged(self) -> None:
68 assert _normalise_url("https://musehub.ai") == "https://musehub.ai"
69
70 def test_trailing_slash_stripped(self) -> None:
71 assert _normalise_url("https://musehub.ai/") == "https://musehub.ai"
72
73 def test_http_url_raises(self) -> None:
74 with pytest.raises(ValueError, match="Insecure"):
75 _normalise_url("http://musehub.ai")
76
77 def test_http_suggests_https(self) -> None:
78 with pytest.raises(ValueError, match="https://"):
79 _normalise_url("http://musehub.ai")
80
81 def test_whitespace_stripped(self) -> None:
82 assert _normalise_url(" https://musehub.ai ") == "https://musehub.ai"
83
84
85 class TestHubHostname:
86 def test_extracts_hostname_from_https_url(self) -> None:
87 assert _hub_hostname("https://musehub.ai/repos/r1") == "musehub.ai"
88
89 def test_bare_hostname(self) -> None:
90 assert _hub_hostname("musehub.ai") == "musehub.ai"
91
92 def test_strips_path(self) -> None:
93 assert _hub_hostname("https://musehub.ai/deep/path") == "musehub.ai"
94
95 def test_preserves_port(self) -> None:
96 assert _hub_hostname("https://musehub.ai:8443") == "musehub.ai:8443"
97
98
99 class TestPingHub:
100 def test_2xx_returns_true(self) -> None:
101 mock_resp = unittest.mock.MagicMock()
102 mock_resp.status = 200
103 mock_resp.__enter__ = lambda s: s
104 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
105 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp):
106 ok, msg = _ping_hub("https://musehub.ai")
107 assert ok is True
108 assert "200" in msg
109
110 def test_5xx_returns_false(self) -> None:
111 mock_resp = unittest.mock.MagicMock()
112 mock_resp.status = 503
113 mock_resp.__enter__ = lambda s: s
114 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
115 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp):
116 ok, msg = _ping_hub("https://musehub.ai")
117 assert ok is False
118
119 def test_http_error_returns_false(self) -> None:
120 err = urllib.error.HTTPError("https://musehub.ai/health", 401, "Unauthorized", {}, io.BytesIO(b"Unauthorized"))
121 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err):
122 ok, msg = _ping_hub("https://musehub.ai")
123 assert ok is False
124 assert "401" in msg
125
126 def test_url_error_returns_false(self) -> None:
127 err = urllib.error.URLError("name resolution failure")
128 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err):
129 ok, msg = _ping_hub("https://musehub.ai")
130 assert ok is False
131
132 def test_timeout_error_returns_false(self) -> None:
133 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=TimeoutError()):
134 ok, msg = _ping_hub("https://musehub.ai")
135 assert ok is False
136 assert "timed out" in msg
137
138 def test_os_error_returns_false(self) -> None:
139 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=OSError("network down")):
140 ok, msg = _ping_hub("https://musehub.ai")
141 assert ok is False
142
143 def test_health_endpoint_used(self) -> None:
144 calls: list[str] = []
145
146 def _fake_open(req: urllib.request.Request, timeout: int = 0) -> urllib.response.addinfourl:
147 calls.append(req.full_url)
148 raise urllib.error.URLError("stop")
149
150 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=_fake_open):
151 _ping_hub("https://musehub.ai")
152 assert calls and calls[0] == "https://musehub.ai/health"
153
154
155 # ---------------------------------------------------------------------------
156 # hub connect
157 # ---------------------------------------------------------------------------
158
159
160 class TestHubConnect:
161 def test_connect_bare_hostname(self, repo: pathlib.Path) -> None:
162 result = runner.invoke(cli, ["hub", "connect", "musehub.ai"])
163 assert result.exit_code == 0
164 assert "Connected" in result.output
165
166 def test_connect_stores_https_url(self, repo: pathlib.Path) -> None:
167 runner.invoke(cli, ["hub", "connect", "musehub.ai"])
168 stored = get_hub_url(repo)
169 assert stored == "https://musehub.ai"
170
171 def test_connect_https_url_directly(self, repo: pathlib.Path) -> None:
172 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
173 assert result.exit_code == 0
174 assert get_hub_url(repo) == "https://musehub.ai"
175
176 def test_connect_http_rejected(self, repo: pathlib.Path) -> None:
177 result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"])
178 assert result.exit_code != 0
179 assert "Insecure" in result.output or "rejected" in result.output
180
181 def test_connect_warns_on_hub_switch(self, repo: pathlib.Path) -> None:
182 runner.invoke(cli, ["hub", "connect", "https://hub1.example.com"])
183 result = runner.invoke(cli, ["hub", "connect", "https://hub2.example.com"])
184 assert result.exit_code == 0
185 assert "hub1.example.com" in result.output or "Switching" in result.output
186
187 def test_connect_shows_identity_if_already_logged_in(self, repo: pathlib.Path) -> None:
188 entry: IdentityEntry = {"type": "human", "handle": "Alice"}
189 save_identity("https://musehub.ai", entry)
190 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
191 assert result.exit_code == 0
192 assert "Alice" in result.output or "human" in result.output
193
194 def test_connect_prompts_login_when_no_identity(self, repo: pathlib.Path) -> None:
195 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
196 assert result.exit_code == 0
197 assert "muse auth" in result.output
198
199 def test_connect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
200 monkeypatch.chdir(tmp_path)
201 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
202 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
203 assert result.exit_code != 0
204
205
206 # ---------------------------------------------------------------------------
207 # hub status
208 # ---------------------------------------------------------------------------
209
210
211 class TestHubStatus:
212 def _setup_hub(self, repo: pathlib.Path) -> None:
213 set_hub_url("https://musehub.ai", repo)
214
215 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
216 result = runner.invoke(cli, ["hub", "status"])
217 assert result.exit_code != 0
218
219 def test_hub_url_shown(self, repo: pathlib.Path) -> None:
220 self._setup_hub(repo)
221 result = runner.invoke(cli, ["hub", "status"])
222 assert result.exit_code == 0
223 assert "musehub.ai" in result.output
224
225 def test_not_authenticated_shown(self, repo: pathlib.Path) -> None:
226 self._setup_hub(repo)
227 result = runner.invoke(cli, ["hub", "status"])
228 assert "not authenticated" in result.output or "auth" in result.output
229
230 def test_identity_fields_shown_when_logged_in(self, repo: pathlib.Path) -> None:
231 self._setup_hub(repo)
232 entry: IdentityEntry = {"type": "agent", "handle": "bot", "fingerprint": "agt_001"}
233 save_identity("https://musehub.ai", entry)
234 result = runner.invoke(cli, ["hub", "status"])
235 assert "agent" in result.output
236 assert "bot" in result.output
237
238 def test_json_output_structure(self, repo: pathlib.Path) -> None:
239 self._setup_hub(repo)
240 result = runner.invoke(cli, ["hub", "status", "--json"])
241 assert result.exit_code == 0
242 data = json.loads(result.output)
243 assert "hub_url" in data
244 assert "hostname" in data
245 assert "authenticated" in data
246
247 def test_json_output_with_identity(self, repo: pathlib.Path) -> None:
248 self._setup_hub(repo)
249 entry: IdentityEntry = {"type": "human", "handle": "Alice", "fingerprint": "usr_1"}
250 save_identity("https://musehub.ai", entry)
251 result = runner.invoke(cli, ["hub", "status", "--json"])
252 data = json.loads(result.output)
253 assert data["authenticated"] is True
254 assert data["identity_type"] == "human"
255 assert data["identity_name"] == "Alice"
256
257
258 # ---------------------------------------------------------------------------
259 # hub disconnect
260 # ---------------------------------------------------------------------------
261
262
263 class TestHubDisconnect:
264 def test_disconnect_clears_hub_url(self, repo: pathlib.Path) -> None:
265 set_hub_url("https://musehub.ai", repo)
266 result = runner.invoke(cli, ["hub", "disconnect"])
267 assert result.exit_code == 0
268 assert get_hub_url(repo) is None
269
270 def test_disconnect_shows_hostname(self, repo: pathlib.Path) -> None:
271 set_hub_url("https://musehub.ai", repo)
272 result = runner.invoke(cli, ["hub", "disconnect"])
273 assert "musehub.ai" in result.output
274
275 def test_disconnect_nothing_to_do(self, repo: pathlib.Path) -> None:
276 result = runner.invoke(cli, ["hub", "disconnect"])
277 assert result.exit_code == 0
278 assert "nothing" in result.output.lower() or "No hub" in result.output
279
280 def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None:
281 """Credentials in identity.toml must survive hub disconnect."""
282 set_hub_url("https://musehub.ai", repo)
283 entry: IdentityEntry = {"type": "human", "handle": "alice"}
284 save_identity("https://musehub.ai", entry)
285 runner.invoke(cli, ["hub", "disconnect"])
286 from muse.core.identity import load_identity
287 assert load_identity("https://musehub.ai") is not None
288
289 def test_disconnect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
290 monkeypatch.chdir(tmp_path)
291 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
292 result = runner.invoke(cli, ["hub", "disconnect"])
293 assert result.exit_code != 0
294
295
296 # ---------------------------------------------------------------------------
297 # hub ping
298 # ---------------------------------------------------------------------------
299
300
301 class TestHubPing:
302 def _setup_hub(self, repo: pathlib.Path) -> None:
303 set_hub_url("https://musehub.ai", repo)
304
305 def test_ping_success(self, repo: pathlib.Path) -> None:
306 self._setup_hub(repo)
307 mock_resp = unittest.mock.MagicMock()
308 mock_resp.status = 200
309 mock_resp.__enter__ = lambda s: s
310 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
311 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp):
312 result = runner.invoke(cli, ["hub", "ping"])
313 assert result.exit_code == 0
314 assert "200" in result.output or "OK" in result.output.upper()
315
316 def test_ping_failure_exits_nonzero(self, repo: pathlib.Path) -> None:
317 self._setup_hub(repo)
318 err = urllib.error.URLError("no route to host")
319 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err):
320 result = runner.invoke(cli, ["hub", "ping"])
321 assert result.exit_code != 0
322
323 def test_ping_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
324 result = runner.invoke(cli, ["hub", "ping"])
325 assert result.exit_code != 0
326
327 def test_ping_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
328 monkeypatch.chdir(tmp_path)
329 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
330 result = runner.invoke(cli, ["hub", "ping"])
331 assert result.exit_code != 0
332
333
334 # ---------------------------------------------------------------------------
335 # _enrich_hub_url_from_remote
336 # ---------------------------------------------------------------------------
337
338
339 class TestEnrichHubUrlFromRemote:
340 """_enrich_hub_url_from_remote appends owner/slug from a matching remote."""
341
342 def _write_remote(self, repo: pathlib.Path, name: str, url: str) -> None:
343 from muse.cli.config import set_remote
344 set_remote(name, url, repo)
345
346 def test_enriches_bare_hub_url_with_owner_slug(self, repo: pathlib.Path) -> None:
347 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
348 self._write_remote(repo, "local", "https://localhost:1337/gabriel/muse")
349 result = _enrich_hub_url_from_remote("https://localhost:1337")
350 assert result == "https://localhost:1337/gabriel/muse"
351
352 def test_leaves_already_enriched_url_unchanged(self, repo: pathlib.Path) -> None:
353 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
354 self._write_remote(repo, "local", "https://localhost:1337/gabriel/muse")
355 result = _enrich_hub_url_from_remote("https://localhost:1337/gabriel/muse")
356 assert result == "https://localhost:1337/gabriel/muse"
357
358 def test_returns_bare_url_when_no_matching_remote(self, repo: pathlib.Path) -> None:
359 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
360 # remote is on a different host — no match
361 self._write_remote(repo, "staging", "http://otherhost:10003/gabriel/muse")
362 result = _enrich_hub_url_from_remote("https://localhost:1337")
363 assert result == "https://localhost:1337"
364
365 def test_returns_bare_url_when_no_remotes_configured(self, repo: pathlib.Path) -> None:
366 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
367 result = _enrich_hub_url_from_remote("https://localhost:1337")
368 assert result == "https://localhost:1337"
369
370 def test_returns_bare_url_outside_repo(
371 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
372 ) -> None:
373 monkeypatch.chdir(tmp_path)
374 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
375 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
376 result = _enrich_hub_url_from_remote("https://localhost:1337")
377 assert result == "https://localhost:1337"
378
379
380 # ---------------------------------------------------------------------------
381 # muse hub issue read (renamed from "get")
382 # ---------------------------------------------------------------------------
383
384
385 class TestHubIssueReadCommand:
386 """'muse hub issue read' is the canonical verb — 'get' is gone."""
387
388 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
389 set_hub_url(hub_url, repo)
390 identity = IdentityEntry(
391 type="human",
392 handle="gabriel",
393 algorithm="ed25519",
394 fingerprint="deadbeef",
395 )
396 save_identity(hub_url, identity)
397
398 def test_issue_read_subcommand_exists(self, repo: pathlib.Path) -> None:
399 """'read' must be a valid subcommand — exit code must not be 2 (unknown subcommand)."""
400 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
401 mock_resp = {"number": 1, "title": "Test", "state": "open", "body": ""}
402 with unittest.mock.patch(
403 "muse.cli.commands.hub.issues._hub_api", return_value=mock_resp
404 ):
405 result = runner.invoke(cli, ["hub", "issue", "read", "1"])
406 # A valid subcommand that fails for other reasons (auth, network) gives != 2
407 # The key assertion: exit_code 2 means "unrecognised subcommand" — that must not happen.
408 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
409
410 def test_issue_get_subcommand_removed(self, repo: pathlib.Path) -> None:
411 """'get' must no longer be accepted."""
412 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
413 result = runner.invoke(cli, ["hub", "issue", "get", "1"])
414 assert result.exit_code != 0
415
416
417 # ---------------------------------------------------------------------------
418 # muse hub proposal read (renamed from "view")
419 # ---------------------------------------------------------------------------
420
421
422 class TestHubProposalReadCommand:
423 """'muse hub proposal read' is the canonical verb — 'view' is gone."""
424
425 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
426 set_hub_url(hub_url, repo)
427 identity = IdentityEntry(
428 type="human",
429 handle="gabriel",
430 algorithm="ed25519",
431 fingerprint="deadbeef",
432 )
433 save_identity(hub_url, identity)
434
435 def test_proposal_read_subcommand_exists(self, repo: pathlib.Path) -> None:
436 """'read' must be a valid subcommand — exit code must not be 2."""
437 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
438 mock_proposal = {
439 "proposalId": "abc123",
440 "number": 1,
441 "title": "Test",
442 "state": "open",
443 "headBranch": "feat/x",
444 "baseBranch": "dev",
445 "author": "gabriel",
446 "createdAt": "2026-04-09T00:00:00Z",
447 "body": "",
448 }
449 with unittest.mock.patch(
450 "muse.cli.commands.hub.proposals._hub_api", return_value={"proposals": [mock_proposal]}
451 ):
452 result = runner.invoke(cli, ["hub", "proposal", "read", "abc123"])
453 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
454
455 def test_proposal_view_subcommand_removed(self, repo: pathlib.Path) -> None:
456 """'view' must no longer be accepted."""
457 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
458 result = runner.invoke(cli, ["hub", "proposal", "view", "abc123"])
459 assert result.exit_code != 0
460
461
462 # ---------------------------------------------------------------------------
463 # muse hub issue update (renamed from "edit")
464 # ---------------------------------------------------------------------------
465
466
467 class TestHubIssueUpdateCommand:
468 """'muse hub issue update' is the canonical verb — 'edit' is gone."""
469
470 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
471 set_hub_url(hub_url, repo)
472 identity = IdentityEntry(
473 type="human",
474 handle="gabriel",
475 algorithm="ed25519",
476 fingerprint="deadbeef",
477 )
478 save_identity(hub_url, identity)
479
480 def test_issue_update_subcommand_exists(self, repo: pathlib.Path) -> None:
481 """'update' must be a valid subcommand — exit code must not be 2 (unknown subcommand)."""
482 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
483 mock_resp = {"number": 1, "title": "Updated", "state": "open", "body": "new body"}
484 with unittest.mock.patch(
485 "muse.cli.commands.hub.issues._hub_api", return_value=mock_resp
486 ):
487 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--body", "new body"])
488 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
489
490 def test_issue_edit_subcommand_removed(self, repo: pathlib.Path) -> None:
491 """'edit' must no longer be accepted."""
492 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
493 result = runner.invoke(cli, ["hub", "issue", "edit", "1", "--body", "x"])
494 assert result.exit_code != 0
495
496 def _hub_patches(self, calls: list[tuple]):
497 """Return a context manager that mocks all three hub network helpers."""
498 import unittest.mock as mock
499 mock_resp = {"number": 1, "title": "x", "state": "open"}
500
501 def _fake_api(hub_url, identity, method, path, **kw):
502 calls.append((method, path))
503 return mock_resp
504
505 return mock.patch.multiple(
506 "muse.cli.commands.hub",
507 _hub_api=mock.MagicMock(side_effect=_fake_api),
508 _get_hub_and_identity=mock.MagicMock(
509 return_value=("https://localhost:1337", mock.MagicMock())
510 ),
511 _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"),
512 )
513
514 def test_issue_update_status_closed_calls_close_endpoint(self, repo: pathlib.Path) -> None:
515 """--status closed must call the /close endpoint, not PATCH."""
516 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
517 calls: list[tuple] = []
518 with self._hub_patches(calls):
519 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "closed"])
520 assert result.exit_code == 0, result.output
521 assert any("/close" in path for _, path in calls), (
522 f"--status closed must call the /close endpoint; got calls: {calls}"
523 )
524
525 def test_issue_update_status_open_calls_reopen_endpoint(self, repo: pathlib.Path) -> None:
526 """--status open must call the /reopen endpoint, not PATCH."""
527 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
528 calls: list[tuple] = []
529 with self._hub_patches(calls):
530 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "open"])
531 assert result.exit_code == 0, result.output
532 assert any("/reopen" in path for _, path in calls), (
533 f"--status open must call the /reopen endpoint; got calls: {calls}"
534 )
535
536 def test_issue_update_status_invalid_value_rejected(self, repo: pathlib.Path) -> None:
537 """--status must only accept 'open' or 'closed' — argparse rejects anything else."""
538 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
539 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "pending"])
540 assert result.exit_code != 0
541
542 def test_issue_update_status_and_body_together(self, repo: pathlib.Path) -> None:
543 """--status closed combined with --body must close AND update the body."""
544 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
545 calls: list[tuple] = []
546 with self._hub_patches(calls):
547 result = runner.invoke(
548 cli, ["hub", "issue", "update", "1", "--status", "closed", "--body", "done"]
549 )
550 assert result.exit_code == 0, result.output
551 assert any("/close" in p for _, p in calls), "close endpoint not called"
552 assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update"
553
554 def test_issue_update_assign_calls_assign_endpoint(self, repo: pathlib.Path) -> None:
555 """--assign <user> must POST to the /assign endpoint."""
556 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
557 calls: list[tuple] = []
558 with self._hub_patches(calls):
559 result = runner.invoke(
560 cli, ["hub", "issue", "update", "1", "--assign", "gabriel"]
561 )
562 assert result.exit_code == 0, result.output
563 assert any("/assign" in path for _, path in calls), (
564 f"--assign must call the /assign endpoint; got: {calls}"
565 )
566
567 def test_issue_update_assign_and_body_together(self, repo: pathlib.Path) -> None:
568 """--assign combined with --body must PATCH the body AND POST to /assign."""
569 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
570 calls: list[tuple] = []
571 with self._hub_patches(calls):
572 result = runner.invoke(
573 cli,
574 ["hub", "issue", "update", "1", "--assign", "gabriel", "--body", "impl done"],
575 )
576 assert result.exit_code == 0, result.output
577 assert any("/assign" in p for _, p in calls), "assign endpoint not called"
578 assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update"
579
580 def test_issue_update_assign_and_status_together(self, repo: pathlib.Path) -> None:
581 """--assign + --status closed must call /assign AND /close."""
582 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
583 calls: list[tuple] = []
584 with self._hub_patches(calls):
585 result = runner.invoke(
586 cli,
587 ["hub", "issue", "update", "1", "--assign", "gabriel", "--status", "closed"],
588 )
589 assert result.exit_code == 0, result.output
590 assert any("/assign" in p for _, p in calls), "assign endpoint not called"
591 assert any("/close" in p for _, p in calls), "close endpoint not called"
592
593
594 # ---------------------------------------------------------------------------
595 # muse hub repo update (renamed from "settings")
596 # ---------------------------------------------------------------------------
597
598
599 class TestHubRepoUpdateCommand:
600 """'muse hub repo update' is the canonical verb — 'settings' is gone."""
601
602 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
603 set_hub_url(hub_url, repo)
604 identity = IdentityEntry(
605 type="human",
606 handle="gabriel",
607 algorithm="ed25519",
608 fingerprint="deadbeef",
609 )
610 save_identity(hub_url, identity)
611
612 def test_repo_update_subcommand_exists(self, repo: pathlib.Path) -> None:
613 """'update' must be a valid repo subcommand — exit code must not be 2."""
614 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
615 mock_resp = {"repoId": "abc", "name": "muse", "owner": "gabriel"}
616 with unittest.mock.patch(
617 "muse.cli.commands.hub.repos._hub_api", return_value=mock_resp
618 ):
619 result = runner.invoke(cli, ["hub", "repo", "update", "--description", "x"])
620 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
621
622 def test_repo_settings_subcommand_removed(self, repo: pathlib.Path) -> None:
623 """'settings' must no longer be accepted."""
624 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
625 result = runner.invoke(cli, ["hub", "repo", "settings"])
626 assert result.exit_code != 0
627
628
629 # ---------------------------------------------------------------------------
630 # muse hub repo transfer-ownership (renamed from "transfer")
631 # ---------------------------------------------------------------------------
632
633
634 class TestHubRepoTransferOwnershipCommand:
635 """'muse hub repo transfer-ownership' is the canonical verb — 'transfer' is gone."""
636
637 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
638 set_hub_url(hub_url, repo)
639 identity = IdentityEntry(
640 type="human",
641 handle="gabriel",
642 algorithm="ed25519",
643 fingerprint="deadbeef",
644 )
645 save_identity(hub_url, identity)
646
647 def test_repo_transfer_ownership_subcommand_exists(self, repo: pathlib.Path) -> None:
648 """'transfer-ownership' must be a valid repo subcommand — exit code must not be 2."""
649 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
650 mock_resp = {"repoId": "abc", "name": "muse", "owner": "bob"}
651 with unittest.mock.patch(
652 "muse.cli.commands.hub.repos._hub_api", return_value=mock_resp
653 ):
654 result = runner.invoke(cli, ["hub", "repo", "transfer-ownership", "--new-owner", "bob"])
655 assert result.exit_code != 2, f"'transfer-ownership' is not a recognised subcommand: {result.output}"
656
657 def test_repo_transfer_subcommand_removed(self, repo: pathlib.Path) -> None:
658 """'transfer' must no longer be accepted."""
659 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
660 result = runner.invoke(cli, ["hub", "repo", "transfer", "--new-owner", "bob"])
661 assert result.exit_code != 0
662
663
664 # ---------------------------------------------------------------------------
665 # muse hub collaborator update-permission (renamed from "update")
666 # ---------------------------------------------------------------------------
667
668
669 class TestHubCollaboratorUpdatePermissionCommand:
670 """'muse hub collaborator update-permission' is the canonical verb — 'update' is gone."""
671
672 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
673 set_hub_url(hub_url, repo)
674 identity = IdentityEntry(
675 type="human",
676 handle="gabriel",
677 algorithm="ed25519",
678 fingerprint="deadbeef",
679 )
680 save_identity(hub_url, identity)
681
682 def test_collaborator_update_permission_subcommand_exists(self, repo: pathlib.Path) -> None:
683 """'update-permission' must be a valid collaborator subcommand — exit code must not be 2."""
684 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
685 mock_resp = {"handle": "carol", "permission": "admin"}
686 with unittest.mock.patch(
687 "muse.cli.commands.hub.collaborators._hub_api", return_value=mock_resp
688 ):
689 result = runner.invoke(
690 cli, ["hub", "collaborator", "update-permission", "carol", "--permission", "admin"]
691 )
692 assert result.exit_code != 2, f"'update-permission' is not a recognised subcommand: {result.output}"
693
694 def test_collaborator_update_subcommand_removed(self, repo: pathlib.Path) -> None:
695 """'update' must no longer be accepted as a collaborator subcommand."""
696 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
697 result = runner.invoke(
698 cli, ["hub", "collaborator", "update", "carol", "--permission", "admin"]
699 )
700 assert result.exit_code != 0
701
702
703 # ---------------------------------------------------------------------------
704 # muse hub repo list
705 # ---------------------------------------------------------------------------
706
707
708 class TestHubRepoListCommand:
709 """'muse hub repo list' lists repos for the authenticated user."""
710
711 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
712 set_hub_url(hub_url, repo)
713 identity = IdentityEntry(
714 type="human",
715 handle="gabriel",
716 algorithm="ed25519",
717 fingerprint="deadbeef",
718 )
719 save_identity(hub_url, identity)
720
721 def test_repo_list_subcommand_exists(self, repo: pathlib.Path) -> None:
722 """'list' must be a valid repo subcommand — exit code must not be 2."""
723 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
724 mock_resp = {
725 "total": 1,
726 "nextCursor": None,
727 "repos": [
728 {
729 "repoId": "abc",
730 "name": "my-repo",
731 "owner": "gabriel",
732 "slug": "my-repo",
733 "visibility": "public",
734 "description": "Test repo",
735 "tags": [],
736 "defaultBranch": "main",
737 "createdAt": "2026-01-01T00:00:00Z",
738 "pushedAt": "",
739 }
740 ],
741 }
742 with unittest.mock.patch.multiple(
743 "muse.cli.commands.hub",
744 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
745 _get_hub_and_identity=unittest.mock.MagicMock(
746 return_value=("https://localhost:1337", unittest.mock.MagicMock())
747 ),
748 ):
749 result = runner.invoke(cli, ["hub", "repo", "list"])
750 assert result.exit_code != 2, f"'list' is not a recognised subcommand: {result.output}"
751
752 def test_repo_list_json_output_structure(self, repo: pathlib.Path) -> None:
753 """--json emits total, next_cursor, and repos list to stdout."""
754 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
755 mock_resp = {
756 "total": 2,
757 "nextCursor": None,
758 "repos": [
759 {
760 "repoId": "id1",
761 "name": "alpha",
762 "owner": "gabriel",
763 "slug": "alpha",
764 "visibility": "public",
765 "description": "",
766 "tags": [],
767 "defaultBranch": "main",
768 "createdAt": "2026-01-01T00:00:00Z",
769 "pushedAt": "",
770 },
771 {
772 "repoId": "id2",
773 "name": "beta",
774 "owner": "gabriel",
775 "slug": "beta",
776 "visibility": "private",
777 "description": "private repo",
778 "tags": ["music"],
779 "defaultBranch": "dev",
780 "createdAt": "2026-01-02T00:00:00Z",
781 "pushedAt": "",
782 },
783 ],
784 }
785 with unittest.mock.patch.multiple(
786 "muse.cli.commands.hub",
787 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
788 _get_hub_and_identity=unittest.mock.MagicMock(
789 return_value=("https://localhost:1337", unittest.mock.MagicMock())
790 ),
791 ):
792 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
793
794 assert result.exit_code == 0, result.output
795 out = json.loads(result.output)
796 assert out["total"] == 2
797 assert out["next_cursor"] is None
798 assert len(out["repos"]) == 2
799 slugs = [r["slug"] for r in out["repos"]]
800 assert "alpha" in slugs
801 assert "beta" in slugs
802
803 def test_repo_list_json_fields_present(self, repo: pathlib.Path) -> None:
804 """Each repo in --json output has all documented fields."""
805 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
806 mock_resp = {
807 "total": 1,
808 "nextCursor": None,
809 "repos": [
810 {
811 "repoId": "abc",
812 "name": "check-fields",
813 "owner": "gabriel",
814 "slug": "check-fields",
815 "visibility": "public",
816 "description": "desc",
817 "tags": ["a", "b"],
818 "defaultBranch": "main",
819 "createdAt": "2026-01-01T00:00:00Z",
820 "pushedAt": "2026-03-01T00:00:00Z",
821 }
822 ],
823 }
824 with unittest.mock.patch.multiple(
825 "muse.cli.commands.hub",
826 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
827 _get_hub_and_identity=unittest.mock.MagicMock(
828 return_value=("https://localhost:1337", unittest.mock.MagicMock())
829 ),
830 ):
831 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
832
833 assert result.exit_code == 0
834 repos = json.loads(result.output)["repos"]
835 assert len(repos) == 1
836 for field in ("repo_id", "name", "owner", "slug", "visibility",
837 "description", "tags", "default_branch", "created_at", "pushed_at"):
838 assert field in repos[0], f"missing field: {field}"
839
840 def test_repo_list_passes_limit_to_api(self, repo: pathlib.Path) -> None:
841 """--limit is forwarded as a query parameter."""
842 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
843 captured: list[str] = []
844
845 def fake_api(hub_url: str, identity, method: str, path: str, **kwargs):
846 captured.append(path)
847 return {"total": 0, "nextCursor": None, "repos": []}
848
849 with unittest.mock.patch.multiple(
850 "muse.cli.commands.hub",
851 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
852 _get_hub_and_identity=unittest.mock.MagicMock(
853 return_value=("https://localhost:1337", unittest.mock.MagicMock())
854 ),
855 ):
856 runner.invoke(cli, ["hub", "repo", "list", "--limit", "42", "--json"])
857
858 assert captured, "no API call made"
859 assert "limit=42" in captured[0]
860
861 def test_repo_list_empty_prints_no_repos(self, repo: pathlib.Path) -> None:
862 """Empty repo list exits 0 and does not crash."""
863 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
864 mock_resp = {"total": 0, "nextCursor": None, "repos": []}
865 with unittest.mock.patch.multiple(
866 "muse.cli.commands.hub",
867 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
868 _get_hub_and_identity=unittest.mock.MagicMock(
869 return_value=("https://localhost:1337", unittest.mock.MagicMock())
870 ),
871 ):
872 result = runner.invoke(cli, ["hub", "repo", "list"])
873 assert result.exit_code == 0
874
875
876 # ---------------------------------------------------------------------------
877 # muse hub repo read
878 # ---------------------------------------------------------------------------
879
880
881 class TestHubRepoReadCommand:
882 """'muse hub repo read' fetches metadata for a single repo."""
883
884 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
885 set_hub_url(hub_url, repo)
886 identity = IdentityEntry(
887 type="human",
888 handle="gabriel",
889 algorithm="ed25519",
890 fingerprint="deadbeef",
891 )
892 save_identity(hub_url, identity)
893
894 _MOCK_REPO = {
895 "repoId": "abc123",
896 "name": "jazz-standards",
897 "owner": "gabriel",
898 "slug": "jazz-standards",
899 "visibility": "public",
900 "description": "A collection of jazz standards",
901 "tags": ["music", "jazz"],
902 "defaultBranch": "main",
903 "cloneUrl": "https://localhost:1337/gabriel/jazz-standards",
904 "createdAt": "2026-01-01T00:00:00Z",
905 "updatedAt": "2026-02-01T00:00:00Z",
906 "pushedAt": "2026-03-01T00:00:00Z",
907 }
908
909 def test_repo_read_subcommand_exists(self, repo: pathlib.Path) -> None:
910 """'read' must be a valid repo subcommand — exit code must not be 2."""
911 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
912 with unittest.mock.patch.multiple(
913 "muse.cli.commands.hub",
914 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
915 _get_hub_and_identity=unittest.mock.MagicMock(
916 return_value=("https://localhost:1337", unittest.mock.MagicMock())
917 ),
918 ):
919 result = runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards"])
920 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
921
922 def test_repo_read_json_output_structure(self, repo: pathlib.Path) -> None:
923 """--json emits all documented repo fields to stdout."""
924 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
925 with unittest.mock.patch.multiple(
926 "muse.cli.commands.hub",
927 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
928 _get_hub_and_identity=unittest.mock.MagicMock(
929 return_value=("https://localhost:1337", unittest.mock.MagicMock())
930 ),
931 ):
932 result = runner.invoke(
933 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
934 )
935
936 assert result.exit_code == 0, result.output
937 out = json.loads(result.output)
938 assert out["repo_id"] == "abc123"
939 assert out["slug"] == "jazz-standards"
940 assert out["owner"] == "gabriel"
941 assert out["visibility"] == "public"
942
943 def test_repo_read_json_fields_present(self, repo: pathlib.Path) -> None:
944 """All documented fields are present in --json output."""
945 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
946 with unittest.mock.patch.multiple(
947 "muse.cli.commands.hub",
948 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
949 _get_hub_and_identity=unittest.mock.MagicMock(
950 return_value=("https://localhost:1337", unittest.mock.MagicMock())
951 ),
952 ):
953 result = runner.invoke(
954 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
955 )
956
957 assert result.exit_code == 0
958 out = json.loads(result.output)
959 for field in ("repo_id", "name", "owner", "slug", "visibility",
960 "description", "tags", "default_branch",
961 "clone_url", "created_at", "updated_at", "pushed_at"):
962 assert field in out, f"missing field: {field}"
963
964 def test_repo_read_uses_owner_slug_url(self, repo: pathlib.Path) -> None:
965 """OWNER/SLUG argument resolves via /api/{owner}/{slug} not /api/repos/{id}."""
966 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
967 captured: list[str] = []
968
969 def fake_api(hub_url: str, identity, method: str, path: str, **kwargs):
970 captured.append(path)
971 return self._MOCK_REPO
972
973 with unittest.mock.patch.multiple(
974 "muse.cli.commands.hub",
975 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
976 _get_hub_and_identity=unittest.mock.MagicMock(
977 return_value=("https://localhost:1337", unittest.mock.MagicMock())
978 ),
979 ):
980 runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"])
981
982 assert captured, "no API call made"
983 assert "/api/gabriel/jazz-standards" in captured[0]
984
985 def test_repo_read_tags_preserved(self, repo: pathlib.Path) -> None:
986 """Tags list is preserved intact in JSON output."""
987 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
988 with unittest.mock.patch.multiple(
989 "muse.cli.commands.hub",
990 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
991 _get_hub_and_identity=unittest.mock.MagicMock(
992 return_value=("https://localhost:1337", unittest.mock.MagicMock())
993 ),
994 ):
995 result = runner.invoke(
996 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
997 )
998
999 assert result.exit_code == 0
1000 out = json.loads(result.output)
1001 assert out["tags"] == ["music", "jazz"]
1002
1003
1004 class TestHubRepoDeleteCommand:
1005 """Tests for run_repo_delete with the new TARGET argument."""
1006
1007 def test_delete_by_uuid_calls_correct_endpoint(self) -> None:
1008 """run_repo_delete with a UUID target calls DELETE /api/repos/{uuid}."""
1009 import argparse
1010 import unittest.mock as mock
1011 from muse.cli.commands.hub.repos import run_repo_delete
1012
1013 repo_id = "a3f2c9d1-0000-0000-0000-000000000001"
1014
1015 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
1016 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1017 return_value=("https://localhost:1337", mock.MagicMock())):
1018 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
1019 run_repo_delete(args)
1020
1021 calls = [str(c) for c in m_api.call_args_list]
1022 assert any(f"/api/repos/{repo_id}" in c for c in calls), (
1023 f"Expected DELETE /api/repos/{repo_id}, got: {calls}"
1024 )
1025
1026 def test_delete_by_owner_slug_resolves_then_deletes(self) -> None:
1027 """run_repo_delete with OWNER/SLUG fetches repo_id then DELETEs it."""
1028 import argparse
1029 import unittest.mock as mock
1030 from muse.cli.commands.hub.repos import run_repo_delete
1031
1032 resolved_id = "b4e5d6f7-0000-0000-0000-000000000002"
1033 get_resp = {"repoId": resolved_id, "name": "my-repo", "owner": "gabriel"}
1034
1035 with mock.patch("muse.cli.commands.hub._hub_api",
1036 side_effect=[get_resp, {}]) as m_api, \
1037 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1038 return_value=("https://localhost:1337", mock.MagicMock())):
1039 args = argparse.Namespace(target="gabriel/my-repo", yes=True,
1040 hub=None, json_output=True)
1041 run_repo_delete(args)
1042
1043 calls = m_api.call_args_list
1044 assert len(calls) == 2
1045 # First: GET to resolve owner/slug
1046 assert calls[0].args[2] == "GET"
1047 assert "/api/gabriel/my-repo" in calls[0].args[3]
1048 # Second: DELETE with resolved UUID
1049 assert calls[1].args[2] == "DELETE"
1050 assert f"/api/repos/{resolved_id}" in calls[1].args[3]
1051
1052 def test_delete_without_yes_exits_nonzero_and_skips_api(self) -> None:
1053 """Without --yes, exits non-zero and never calls the API."""
1054 import argparse
1055 import pytest
1056 import unittest.mock as mock
1057 from muse.cli.commands.hub.repos import run_repo_delete
1058
1059 with mock.patch("muse.cli.commands.hub._hub_api") as m_api, \
1060 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1061 return_value=("https://localhost:1337", mock.MagicMock())):
1062 args = argparse.Namespace(target="gabriel/my-repo", yes=False,
1063 hub=None, json_output=False)
1064 with pytest.raises(SystemExit) as exc_info:
1065 run_repo_delete(args)
1066
1067 assert exc_info.value.code != 0
1068 m_api.assert_not_called()
1069
1070 def test_delete_no_target_falls_back_to_config_resolution(self) -> None:
1071 """When target is None, repo_id is resolved from the current directory config."""
1072 import argparse
1073 import unittest.mock as mock
1074 from muse.cli.commands.hub.repos import run_repo_delete
1075
1076 config_repo_id = "c5f6e7a8-0000-0000-0000-000000000003"
1077
1078 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
1079 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1080 return_value=("https://localhost:1337", mock.MagicMock())), \
1081 mock.patch("muse.cli.commands.hub._resolve_repo_id",
1082 return_value=config_repo_id):
1083 args = argparse.Namespace(target=None, yes=True, hub=None, json_output=True)
1084 run_repo_delete(args)
1085
1086 calls = [str(c) for c in m_api.call_args_list]
1087 assert any(f"/api/repos/{config_repo_id}" in c for c in calls), (
1088 f"Expected DELETE using config repo_id, got: {calls}"
1089 )
1090
1091 def test_delete_json_output_emits_structured_result(self) -> None:
1092 """--json flag emits {deleted: true, repo_id: ...} to stdout."""
1093 import argparse
1094 import io
1095 import json as json_mod
1096 import sys
1097 import unittest.mock as mock
1098 from muse.cli.commands.hub.repos import run_repo_delete
1099
1100 repo_id = "d6g7h8i9-0000-0000-0000-000000000004"
1101
1102 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}), \
1103 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1104 return_value=("https://localhost:1337", mock.MagicMock())):
1105 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
1106 captured = io.StringIO()
1107 with mock.patch("sys.stdout", captured):
1108 run_repo_delete(args)
1109
1110 output = json_mod.loads(captured.getvalue())
1111 assert output["deleted"] is True
1112 assert output["repo_id"] == repo_id
1113
1114
1115 # ---------------------------------------------------------------------------
1116 # Agent ergonomics — absorb git/GitHub muscle-memory flags
1117 # ---------------------------------------------------------------------------
1118
1119
1120 def _mock_create_resp() -> MsgpackDict:
1121 return {
1122 "repoId": "abc-123",
1123 "name": "my-repo",
1124 "owner": "gabriel",
1125 "slug": "my-repo",
1126 "visibility": "public",
1127 "description": "",
1128 "cloneUrl": "https://staging.musehub.ai/gabriel/my-repo",
1129 "tags": [],
1130 "createdAt": "2026-04-22T00:00:00Z",
1131 }
1132
1133
1134 class TestRepoCreateVisibilityAlias:
1135 """``hub repo create --visibility public|private`` must work as an alias
1136 for the canonical ``--private`` boolean flag.
1137
1138 Agents trained on GitHub CLI reach for ``--visibility`` reflexively.
1139 Rejecting it with an argparse "unrecognized arguments" error wastes a
1140 round-trip and forces the agent to re-read docs. Absorbing the flag
1141 silently maps it to the right internal value.
1142 """
1143
1144 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
1145 set_hub_url(hub_url, repo)
1146 identity = IdentityEntry(
1147 type="human",
1148 handle="gabriel",
1149 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
1150 algorithm="ed25519",
1151 fingerprint="sha256:" + "a" * 64,
1152 )
1153 save_identity(hub_url, identity)
1154
1155 def test_visibility_public_is_accepted(self, repo: pathlib.Path) -> None:
1156 """``--visibility public`` must not be rejected by the parser."""
1157 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1158 with unittest.mock.patch.multiple(
1159 "muse.cli.commands.hub",
1160 _hub_api=unittest.mock.MagicMock(return_value=_mock_create_resp()),
1161 _get_hub_and_identity=unittest.mock.MagicMock(
1162 return_value=("https://localhost:1337", {"handle": "gabriel"})
1163 ),
1164 ):
1165 result = runner.invoke(
1166 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--json"]
1167 )
1168 assert result.exit_code != 2, (
1169 "--visibility public must not produce 'unrecognized arguments'; "
1170 f"got: {result.output}"
1171 )
1172 assert result.exit_code == 0, result.output
1173
1174 def test_visibility_private_maps_to_private(self, repo: pathlib.Path) -> None:
1175 """``--visibility private`` must create a private repo (same as ``--private``)."""
1176 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1177 captured_payload: list[MsgpackDict] = []
1178
1179 def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: MsgpackDict | None = None, **kw: str) -> MsgpackDict:
1180 if body:
1181 captured_payload.append(body)
1182 return _mock_create_resp()
1183
1184 with unittest.mock.patch.multiple(
1185 "muse.cli.commands.hub",
1186 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
1187 _get_hub_and_identity=unittest.mock.MagicMock(
1188 return_value=("https://localhost:1337", {"handle": "gabriel"})
1189 ),
1190 ):
1191 result = runner.invoke(
1192 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "private", "--json"]
1193 )
1194 assert result.exit_code == 0, result.output
1195 assert captured_payload, "no API call was made"
1196 assert captured_payload[0]["visibility"] == "private", (
1197 "--visibility private must send visibility=private to the API"
1198 )
1199
1200 def test_visibility_public_sends_public(self, repo: pathlib.Path) -> None:
1201 """``--visibility public`` must send visibility=public to the API."""
1202 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1203 captured_payload: list[MsgpackDict] = []
1204
1205 def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: MsgpackDict | None = None, **kw: str) -> MsgpackDict:
1206 if body:
1207 captured_payload.append(body)
1208 return _mock_create_resp()
1209
1210 with unittest.mock.patch.multiple(
1211 "muse.cli.commands.hub",
1212 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
1213 _get_hub_and_identity=unittest.mock.MagicMock(
1214 return_value=("https://localhost:1337", {"handle": "gabriel"})
1215 ),
1216 ):
1217 result = runner.invoke(
1218 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--json"]
1219 )
1220 assert result.exit_code == 0, result.output
1221 assert captured_payload[0]["visibility"] == "public"
1222
1223 def test_visibility_invalid_value_exits_nonzero(self, repo: pathlib.Path) -> None:
1224 """``--visibility protected`` (invalid) must fail with a clear error."""
1225 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1226 result = runner.invoke(
1227 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "protected"]
1228 )
1229 assert result.exit_code != 0, "invalid --visibility value must not succeed"
1230 combined = (result.output or "") + (result.stderr or "")
1231 assert "protected" in combined or "public" in combined or "private" in combined, (
1232 "error must mention the invalid value or valid choices"
1233 )
1234
1235 def test_visibility_conflicts_with_private_flag(self, repo: pathlib.Path) -> None:
1236 """``--visibility public --private`` is contradictory and must be rejected."""
1237 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1238 result = runner.invoke(
1239 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--private"]
1240 )
1241 assert result.exit_code != 0, (
1242 "--visibility public combined with --private is contradictory and must fail"
1243 )
1244
1245
1246 class TestRepoListOwnerFlag:
1247 """``hub repo list --owner`` must give an actionable error, not a generic
1248 argparse rejection.
1249
1250 Agents trained on GitHub CLI reach for ``--owner gabriel`` reflexively.
1251 The correct Muse pattern is to fetch all repos and filter in Python.
1252 The error must explain this and show the exact filter command.
1253 """
1254
1255 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
1256 set_hub_url(hub_url, repo)
1257 identity = IdentityEntry(
1258 type="human",
1259 handle="gabriel",
1260 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
1261 algorithm="ed25519",
1262 fingerprint="sha256:" + "a" * 64,
1263 )
1264 save_identity(hub_url, identity)
1265
1266 def test_owner_flag_is_accepted_by_parser(self, repo: pathlib.Path) -> None:
1267 """``--owner`` must not produce argparse's generic 'unrecognized arguments' error."""
1268 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1269 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1270 assert result.exit_code != 2, (
1271 "--owner must not produce exit code 2 (unrecognized argument); "
1272 f"got output: {result.output}"
1273 )
1274
1275 def test_owner_flag_exits_with_helpful_error(self, repo: pathlib.Path) -> None:
1276 """``--owner`` must exit non-zero with a message explaining the filter pattern."""
1277 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1278 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1279 assert result.exit_code != 0, "--owner must exit non-zero (it is not a real filter)"
1280 combined = (result.output or "") + (result.stderr or "")
1281 assert "owner" in combined.lower(), "error must mention 'owner'"
1282
1283 def test_owner_error_suggests_pipe_pattern(self, repo: pathlib.Path) -> None:
1284 """The error message must show the python-pipe filter pattern."""
1285 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1286 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1287 combined = (result.output or "") + (result.stderr or "")
1288 assert "python" in combined.lower() or "json" in combined.lower(), (
1289 "error must suggest filtering via --json | python3"
1290 )
1291
1292 def test_owner_error_names_the_owner_value(self, repo: pathlib.Path) -> None:
1293 """The error must echo back the owner value so the agent knows it was received."""
1294 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1295 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1296 combined = (result.output or "") + (result.stderr or "")
1297 assert "gabriel" in combined, "error must echo back the owner value"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago