gabriel / muse public

test_cli_hub.py file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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.paths import commits_dir, heads_dir, muse_dir, objects_dir, snapshots_dir
26 from muse.core.types import MsgpackDict, fake_id, long_id
27
28 runner = CliRunner()
29
30
31 # ---------------------------------------------------------------------------
32 # Fixture: minimal Muse repo
33 # ---------------------------------------------------------------------------
34
35
36 @pytest.fixture()
37 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
38 """Minimal .muse/ repo; hub tests don't need commits."""
39 heads_dir(tmp_path).mkdir(parents=True, exist_ok=True)
40 objects_dir(tmp_path).mkdir(parents=True, exist_ok=True)
41 commits_dir(tmp_path).mkdir(parents=True, exist_ok=True)
42 snapshots_dir(tmp_path).mkdir(parents=True, exist_ok=True)
43 (muse_dir(tmp_path) / "repo.json").write_text(
44 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"})
45 )
46 (muse_dir(tmp_path) / "HEAD").write_text("ref: refs/heads/main\n")
47 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
48 monkeypatch.chdir(tmp_path)
49 return tmp_path
50
51
52 # ---------------------------------------------------------------------------
53 # Unit tests for pure helper functions
54 # ---------------------------------------------------------------------------
55
56
57 class TestNormaliseUrl:
58 def test_bare_hostname_gets_https(self) -> None:
59 assert _normalise_url("musehub.ai") == "https://musehub.ai"
60
61 def test_https_url_unchanged(self) -> None:
62 assert _normalise_url("https://musehub.ai") == "https://musehub.ai"
63
64 def test_trailing_slash_stripped(self) -> None:
65 assert _normalise_url("https://musehub.ai/") == "https://musehub.ai"
66
67 def test_http_url_raises(self) -> None:
68 with pytest.raises(ValueError, match="Insecure"):
69 _normalise_url("http://musehub.ai")
70
71 def test_http_suggests_https(self) -> None:
72 with pytest.raises(ValueError, match="https://"):
73 _normalise_url("http://musehub.ai")
74
75 def test_whitespace_stripped(self) -> None:
76 assert _normalise_url(" https://musehub.ai ") == "https://musehub.ai"
77
78
79 class TestHubHostname:
80 def test_extracts_hostname_from_https_url(self) -> None:
81 assert _hub_hostname("https://musehub.ai/repos/r1") == "musehub.ai"
82
83 def test_bare_hostname(self) -> None:
84 assert _hub_hostname("musehub.ai") == "musehub.ai"
85
86 def test_strips_path(self) -> None:
87 assert _hub_hostname("https://musehub.ai/deep/path") == "musehub.ai"
88
89 def test_preserves_port(self) -> None:
90 assert _hub_hostname("https://musehub.ai:8443") == "musehub.ai:8443"
91
92
93 class TestPingHub:
94 def test_2xx_returns_true(self) -> None:
95 mock_resp = unittest.mock.MagicMock()
96 mock_resp.status = 200
97 mock_resp.__enter__ = lambda s: s
98 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
99 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp):
100 ok, msg = _ping_hub("https://musehub.ai")
101 assert ok is True
102 assert "200" in msg
103
104 def test_5xx_returns_false(self) -> None:
105 mock_resp = unittest.mock.MagicMock()
106 mock_resp.status = 503
107 mock_resp.__enter__ = lambda s: s
108 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
109 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp):
110 ok, msg = _ping_hub("https://musehub.ai")
111 assert ok is False
112
113 def test_http_error_returns_false(self) -> None:
114 err = urllib.error.HTTPError("https://musehub.ai/health", 401, "Unauthorized", {}, io.BytesIO(b"Unauthorized"))
115 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err):
116 ok, msg = _ping_hub("https://musehub.ai")
117 assert ok is False
118 assert "401" in msg
119
120 def test_url_error_returns_false(self) -> None:
121 err = urllib.error.URLError("name resolution failure")
122 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err):
123 ok, msg = _ping_hub("https://musehub.ai")
124 assert ok is False
125
126 def test_timeout_error_returns_false(self) -> None:
127 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=TimeoutError()):
128 ok, msg = _ping_hub("https://musehub.ai")
129 assert ok is False
130 assert "timed out" in msg
131
132 def test_os_error_returns_false(self) -> None:
133 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=OSError("network down")):
134 ok, msg = _ping_hub("https://musehub.ai")
135 assert ok is False
136
137 def test_health_endpoint_used(self) -> None:
138 calls: list[str] = []
139
140 def _fake_open(req: urllib.request.Request, timeout: int = 0) -> urllib.response.addinfourl:
141 calls.append(req.full_url)
142 raise urllib.error.URLError("stop")
143
144 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=_fake_open):
145 _ping_hub("https://musehub.ai")
146 assert calls and calls[0] == "https://musehub.ai/health"
147
148
149 # ---------------------------------------------------------------------------
150 # hub connect
151 # ---------------------------------------------------------------------------
152
153
154 class TestHubConnect:
155 def test_connect_bare_hostname(self, repo: pathlib.Path) -> None:
156 result = runner.invoke(cli, ["hub", "connect", "musehub.ai"])
157 assert result.exit_code == 0
158 assert "Connected" in result.stderr
159
160 def test_connect_stores_https_url(self, repo: pathlib.Path) -> None:
161 runner.invoke(cli, ["hub", "connect", "musehub.ai"])
162 stored = get_hub_url(repo)
163 assert stored == "https://musehub.ai"
164
165 def test_connect_https_url_directly(self, repo: pathlib.Path) -> None:
166 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
167 assert result.exit_code == 0
168 assert get_hub_url(repo) == "https://musehub.ai"
169
170 def test_connect_http_rejected(self, repo: pathlib.Path) -> None:
171 result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"])
172 assert result.exit_code != 0
173 assert "Insecure" in result.stderr or "rejected" in result.stderr
174
175 def test_connect_warns_on_hub_switch(self, repo: pathlib.Path) -> None:
176 runner.invoke(cli, ["hub", "connect", "https://hub1.example.com"])
177 result = runner.invoke(cli, ["hub", "connect", "https://hub2.example.com"])
178 assert result.exit_code == 0
179 assert "hub1.example.com" in result.stderr or "Switching" in result.stderr
180
181 def test_connect_shows_identity_if_already_logged_in(self, repo: pathlib.Path) -> None:
182 entry: IdentityEntry = {"type": "human", "handle": "Alice"}
183 save_identity("https://musehub.ai", entry)
184 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
185 assert result.exit_code == 0
186 assert "Alice" in result.stderr or "human" in result.stderr
187
188 def test_connect_prompts_login_when_no_identity(self, repo: pathlib.Path) -> None:
189 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
190 assert result.exit_code == 0
191 assert "muse auth" in result.stderr
192
193 def test_connect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
194 monkeypatch.chdir(tmp_path)
195 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
196 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
197 assert result.exit_code != 0
198
199
200 # ---------------------------------------------------------------------------
201 # hub status
202 # ---------------------------------------------------------------------------
203
204
205 class TestHubStatus:
206 def _setup_hub(self, repo: pathlib.Path) -> None:
207 set_hub_url("https://musehub.ai", repo)
208
209 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
210 result = runner.invoke(cli, ["hub", "status"])
211 assert result.exit_code != 0
212
213 def test_hub_url_shown(self, repo: pathlib.Path) -> None:
214 self._setup_hub(repo)
215 result = runner.invoke(cli, ["hub", "status"])
216 assert result.exit_code == 0
217 assert "musehub.ai" in result.stderr
218
219 def test_not_authenticated_shown(self, repo: pathlib.Path) -> None:
220 self._setup_hub(repo)
221 result = runner.invoke(cli, ["hub", "status"])
222 assert "not authenticated" in result.stderr or "auth" in result.stderr
223
224 def test_identity_fields_shown_when_logged_in(self, repo: pathlib.Path) -> None:
225 self._setup_hub(repo)
226 entry: IdentityEntry = {"type": "agent", "handle": "bot", "fingerprint": "agt_001"}
227 save_identity("https://musehub.ai", entry)
228 result = runner.invoke(cli, ["hub", "status"])
229 assert "agent" in result.stderr
230 assert "bot" in result.stderr
231
232 def test_json_output_structure(self, repo: pathlib.Path) -> None:
233 self._setup_hub(repo)
234 result = runner.invoke(cli, ["hub", "status", "--json"])
235 assert result.exit_code == 0
236 data = json.loads(result.output)
237 assert "hub_url" in data
238 assert "hostname" in data
239 assert "authenticated" in data
240
241 def test_json_output_with_identity(self, repo: pathlib.Path) -> None:
242 self._setup_hub(repo)
243 entry: IdentityEntry = {"type": "human", "handle": "Alice", "fingerprint": "usr_1"}
244 save_identity("https://musehub.ai", entry)
245 result = runner.invoke(cli, ["hub", "status", "--json"])
246 data = json.loads(result.output)
247 assert data["authenticated"] is True
248 assert data["identity_type"] == "human"
249 assert data["identity_name"] == "Alice"
250
251
252 # ---------------------------------------------------------------------------
253 # hub disconnect
254 # ---------------------------------------------------------------------------
255
256
257 class TestHubDisconnect:
258 def test_disconnect_clears_hub_url(self, repo: pathlib.Path) -> None:
259 set_hub_url("https://musehub.ai", repo)
260 result = runner.invoke(cli, ["hub", "disconnect"])
261 assert result.exit_code == 0
262 assert get_hub_url(repo) is None
263
264 def test_disconnect_shows_hostname(self, repo: pathlib.Path) -> None:
265 set_hub_url("https://musehub.ai", repo)
266 result = runner.invoke(cli, ["hub", "disconnect"])
267 assert "musehub.ai" in result.stderr
268
269 def test_disconnect_nothing_to_do(self, repo: pathlib.Path) -> None:
270 result = runner.invoke(cli, ["hub", "disconnect"])
271 assert result.exit_code == 0
272 assert "nothing" in result.stderr.lower() or "No hub" in result.stderr
273
274 def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None:
275 """Credentials in identity.toml must survive hub disconnect."""
276 set_hub_url("https://musehub.ai", repo)
277 entry: IdentityEntry = {"type": "human", "handle": "alice"}
278 save_identity("https://musehub.ai", entry)
279 runner.invoke(cli, ["hub", "disconnect"])
280 from muse.core.identity import load_identity
281 assert load_identity("https://musehub.ai") is not None
282
283 def test_disconnect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
284 monkeypatch.chdir(tmp_path)
285 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
286 result = runner.invoke(cli, ["hub", "disconnect"])
287 assert result.exit_code != 0
288
289
290 # ---------------------------------------------------------------------------
291 # hub ping
292 # ---------------------------------------------------------------------------
293
294
295 class TestHubPing:
296 def _setup_hub(self, repo: pathlib.Path) -> None:
297 set_hub_url("https://musehub.ai", repo)
298
299 def test_ping_success(self, repo: pathlib.Path) -> None:
300 self._setup_hub(repo)
301 mock_resp = unittest.mock.MagicMock()
302 mock_resp.status = 200
303 mock_resp.__enter__ = lambda s: s
304 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
305 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", return_value=mock_resp):
306 result = runner.invoke(cli, ["hub", "ping"])
307 assert result.exit_code == 0
308 assert "200" in result.stderr or "OK" in result.stderr.upper()
309
310 def test_ping_failure_exits_nonzero(self, repo: pathlib.Path) -> None:
311 self._setup_hub(repo)
312 err = urllib.error.URLError("no route to host")
313 with unittest.mock.patch("muse.cli.commands.hub._core._PING_OPENER.open", side_effect=err):
314 result = runner.invoke(cli, ["hub", "ping"])
315 assert result.exit_code != 0
316
317 def test_ping_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
318 result = runner.invoke(cli, ["hub", "ping"])
319 assert result.exit_code != 0
320
321 def test_ping_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
322 monkeypatch.chdir(tmp_path)
323 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
324 result = runner.invoke(cli, ["hub", "ping"])
325 assert result.exit_code != 0
326
327
328 # ---------------------------------------------------------------------------
329 # _enrich_hub_url_from_remote
330 # ---------------------------------------------------------------------------
331
332
333 class TestEnrichHubUrlFromRemote:
334 """_enrich_hub_url_from_remote appends owner/slug from a matching remote."""
335
336 def _write_remote(self, repo: pathlib.Path, name: str, url: str) -> None:
337 from muse.cli.config import set_remote
338 set_remote(name, url, repo)
339
340 def test_enriches_bare_hub_url_with_owner_slug(self, repo: pathlib.Path) -> None:
341 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
342 self._write_remote(repo, "local", "https://localhost:1337/gabriel/muse")
343 result = _enrich_hub_url_from_remote("https://localhost:1337")
344 assert result == "https://localhost:1337/gabriel/muse"
345
346 def test_leaves_already_enriched_url_unchanged(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/gabriel/muse")
350 assert result == "https://localhost:1337/gabriel/muse"
351
352 def test_returns_bare_url_when_no_matching_remote(self, repo: pathlib.Path) -> None:
353 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
354 # remote is on a different host β€” no match
355 self._write_remote(repo, "staging", "http://otherhost:10003/gabriel/muse")
356 result = _enrich_hub_url_from_remote("https://localhost:1337")
357 assert result == "https://localhost:1337"
358
359 def test_returns_bare_url_when_no_remotes_configured(self, repo: pathlib.Path) -> None:
360 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
361 result = _enrich_hub_url_from_remote("https://localhost:1337")
362 assert result == "https://localhost:1337"
363
364 def test_returns_bare_url_outside_repo(
365 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
366 ) -> None:
367 monkeypatch.chdir(tmp_path)
368 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
369 from muse.cli.commands.hub._core import _enrich_hub_url_from_remote
370 result = _enrich_hub_url_from_remote("https://localhost:1337")
371 assert result == "https://localhost:1337"
372
373
374 # ---------------------------------------------------------------------------
375 # muse hub issue read (renamed from "get")
376 # ---------------------------------------------------------------------------
377
378
379 class TestHubIssueReadCommand:
380 """'muse hub issue read' is the canonical verb β€” 'get' is gone."""
381
382 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
383 set_hub_url(hub_url, repo)
384 identity = IdentityEntry(
385 type="human",
386 handle="gabriel",
387 algorithm="ed25519",
388 fingerprint="deadbeef",
389 )
390 save_identity(hub_url, identity)
391
392 def test_issue_read_subcommand_exists(self, repo: pathlib.Path) -> None:
393 """'read' must be a valid subcommand β€” exit code must not be 2 (unknown subcommand)."""
394 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
395 mock_resp = {"number": 1, "title": "Test", "state": "open", "body": ""}
396 with unittest.mock.patch(
397 "muse.cli.commands.hub.issues._hub_api", return_value=mock_resp
398 ):
399 result = runner.invoke(cli, ["hub", "issue", "read", "1"])
400 # A valid subcommand that fails for other reasons (auth, network) gives != 2
401 # The key assertion: exit_code 2 means "unrecognised subcommand" β€” that must not happen.
402 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
403
404 def test_issue_get_subcommand_removed(self, repo: pathlib.Path) -> None:
405 """'get' must no longer be accepted."""
406 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
407 result = runner.invoke(cli, ["hub", "issue", "get", "1"])
408 assert result.exit_code != 0
409
410
411 # ---------------------------------------------------------------------------
412 # muse hub proposal read (renamed from "view")
413 # ---------------------------------------------------------------------------
414
415
416 class TestHubProposalReadCommand:
417 """'muse hub proposal read' is the canonical verb β€” 'view' is gone."""
418
419 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
420 set_hub_url(hub_url, repo)
421 identity = IdentityEntry(
422 type="human",
423 handle="gabriel",
424 algorithm="ed25519",
425 fingerprint="deadbeef",
426 )
427 save_identity(hub_url, identity)
428
429 def test_proposal_read_subcommand_exists(self, repo: pathlib.Path) -> None:
430 """'read' must be a valid subcommand β€” exit code must not be 2."""
431 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
432 mock_proposal = {
433 "proposalId": "abc123",
434 "number": 1,
435 "title": "Test",
436 "state": "open",
437 "headBranch": "feat/x",
438 "baseBranch": "dev",
439 "author": "gabriel",
440 "createdAt": "2026-04-09T00:00:00Z",
441 "body": "",
442 }
443 with unittest.mock.patch(
444 "muse.cli.commands.hub.proposals._hub_api", return_value={"proposals": [mock_proposal]}
445 ):
446 result = runner.invoke(cli, ["hub", "proposal", "read", "abc123"])
447 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
448
449 def test_proposal_view_subcommand_removed(self, repo: pathlib.Path) -> None:
450 """'view' must no longer be accepted."""
451 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
452 result = runner.invoke(cli, ["hub", "proposal", "view", "abc123"])
453 assert result.exit_code != 0
454
455
456 # ---------------------------------------------------------------------------
457 # muse hub issue update (renamed from "edit")
458 # ---------------------------------------------------------------------------
459
460
461 class TestHubIssueUpdateCommand:
462 """'muse hub issue update' is the canonical verb β€” 'edit' is gone."""
463
464 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
465 set_hub_url(hub_url, repo)
466 identity = IdentityEntry(
467 type="human",
468 handle="gabriel",
469 algorithm="ed25519",
470 fingerprint="deadbeef",
471 )
472 save_identity(hub_url, identity)
473
474 def test_issue_update_subcommand_exists(self, repo: pathlib.Path) -> None:
475 """'update' must be a valid subcommand β€” exit code must not be 2 (unknown subcommand)."""
476 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
477 mock_resp = {"number": 1, "title": "Updated", "state": "open", "body": "new body"}
478 with unittest.mock.patch(
479 "muse.cli.commands.hub.issues._hub_api", return_value=mock_resp
480 ):
481 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--body", "new body"])
482 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
483
484 def test_issue_edit_subcommand_removed(self, repo: pathlib.Path) -> None:
485 """'edit' must no longer be accepted."""
486 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
487 result = runner.invoke(cli, ["hub", "issue", "edit", "1", "--body", "x"])
488 assert result.exit_code != 0
489
490 def _hub_patches(self, calls: list[tuple]) -> "unittest.mock._patch": # type: ignore[name-defined]
491 """Return a context manager that mocks all three hub network helpers."""
492 import unittest.mock as mock
493 mock_resp: MsgpackDict = {"number": 1, "title": "x", "state": "open"}
494
495 def _fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kw: str) -> MsgpackDict:
496 calls.append((method, path))
497 return mock_resp
498
499 return mock.patch.multiple(
500 "muse.cli.commands.hub",
501 _hub_api=mock.MagicMock(side_effect=_fake_api),
502 _get_hub_and_identity=mock.MagicMock(
503 return_value=("https://localhost:1337", mock.MagicMock())
504 ),
505 _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"),
506 )
507
508 def test_issue_update_status_closed_calls_close_endpoint(self, repo: pathlib.Path) -> None:
509 """--status closed must call the /close endpoint, not PATCH."""
510 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
511 calls: list[tuple] = []
512 with self._hub_patches(calls):
513 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "closed"])
514 assert result.exit_code == 0, result.output
515 assert any("/close" in path for _, path in calls), (
516 f"--status closed must call the /close endpoint; got calls: {calls}"
517 )
518
519 def test_issue_update_status_open_calls_reopen_endpoint(self, repo: pathlib.Path) -> None:
520 """--status open must call the /reopen endpoint, not PATCH."""
521 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
522 calls: list[tuple] = []
523 with self._hub_patches(calls):
524 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "open"])
525 assert result.exit_code == 0, result.output
526 assert any("/reopen" in path for _, path in calls), (
527 f"--status open must call the /reopen endpoint; got calls: {calls}"
528 )
529
530 def test_issue_update_status_invalid_value_rejected(self, repo: pathlib.Path) -> None:
531 """--status must only accept 'open' or 'closed' β€” argparse rejects anything else."""
532 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
533 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "pending"])
534 assert result.exit_code != 0
535
536 def test_issue_update_status_and_body_together(self, repo: pathlib.Path) -> None:
537 """--status closed combined with --body must close AND update the body."""
538 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
539 calls: list[tuple] = []
540 with self._hub_patches(calls):
541 result = runner.invoke(
542 cli, ["hub", "issue", "update", "1", "--status", "closed", "--body", "done"]
543 )
544 assert result.exit_code == 0, result.output
545 assert any("/close" in p for _, p in calls), "close endpoint not called"
546 assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update"
547
548 def test_issue_update_assign_calls_assign_endpoint(self, repo: pathlib.Path) -> None:
549 """--assign <user> must POST to the /assign endpoint."""
550 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
551 calls: list[tuple] = []
552 with self._hub_patches(calls):
553 result = runner.invoke(
554 cli, ["hub", "issue", "update", "1", "--assign", "gabriel"]
555 )
556 assert result.exit_code == 0, result.output
557 assert any("/assign" in path for _, path in calls), (
558 f"--assign must call the /assign endpoint; got: {calls}"
559 )
560
561 def test_issue_update_assign_and_body_together(self, repo: pathlib.Path) -> None:
562 """--assign combined with --body must PATCH the body AND POST to /assign."""
563 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
564 calls: list[tuple] = []
565 with self._hub_patches(calls):
566 result = runner.invoke(
567 cli,
568 ["hub", "issue", "update", "1", "--assign", "gabriel", "--body", "impl done"],
569 )
570 assert result.exit_code == 0, result.output
571 assert any("/assign" in p for _, p in calls), "assign endpoint not called"
572 assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update"
573
574 def test_issue_update_assign_and_status_together(self, repo: pathlib.Path) -> None:
575 """--assign + --status closed must call /assign AND /close."""
576 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
577 calls: list[tuple] = []
578 with self._hub_patches(calls):
579 result = runner.invoke(
580 cli,
581 ["hub", "issue", "update", "1", "--assign", "gabriel", "--status", "closed"],
582 )
583 assert result.exit_code == 0, result.output
584 assert any("/assign" in p for _, p in calls), "assign endpoint not called"
585 assert any("/close" in p for _, p in calls), "close endpoint not called"
586
587
588 # ---------------------------------------------------------------------------
589 # muse hub repo update (renamed from "settings")
590 # ---------------------------------------------------------------------------
591
592
593 class TestHubRepoUpdateCommand:
594 """'muse hub repo update' is the canonical verb β€” 'settings' is gone."""
595
596 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
597 set_hub_url(hub_url, repo)
598 identity = IdentityEntry(
599 type="human",
600 handle="gabriel",
601 algorithm="ed25519",
602 fingerprint="deadbeef",
603 )
604 save_identity(hub_url, identity)
605
606 def test_repo_update_subcommand_exists(self, repo: pathlib.Path) -> None:
607 """'update' must be a valid repo subcommand β€” exit code must not be 2."""
608 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
609 mock_resp = {"repoId": "abc", "name": "muse", "owner": "gabriel"}
610 with unittest.mock.patch(
611 "muse.cli.commands.hub.repos._hub_api", return_value=mock_resp
612 ):
613 result = runner.invoke(cli, ["hub", "repo", "update", "--description", "x"])
614 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
615
616 def test_repo_settings_subcommand_removed(self, repo: pathlib.Path) -> None:
617 """'settings' must no longer be accepted."""
618 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
619 result = runner.invoke(cli, ["hub", "repo", "settings"])
620 assert result.exit_code != 0
621
622
623 # ---------------------------------------------------------------------------
624 # muse hub repo transfer-ownership (renamed from "transfer")
625 # ---------------------------------------------------------------------------
626
627
628 class TestHubRepoTransferOwnershipCommand:
629 """'muse hub repo transfer-ownership' is the canonical verb β€” 'transfer' is gone."""
630
631 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
632 set_hub_url(hub_url, repo)
633 identity = IdentityEntry(
634 type="human",
635 handle="gabriel",
636 algorithm="ed25519",
637 fingerprint="deadbeef",
638 )
639 save_identity(hub_url, identity)
640
641 def test_repo_transfer_ownership_subcommand_exists(self, repo: pathlib.Path) -> None:
642 """'transfer-ownership' must be a valid repo subcommand β€” exit code must not be 2."""
643 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
644 mock_resp = {"repoId": "abc", "name": "muse", "owner": "bob"}
645 with unittest.mock.patch(
646 "muse.cli.commands.hub.repos._hub_api", return_value=mock_resp
647 ):
648 result = runner.invoke(cli, ["hub", "repo", "transfer-ownership", "--new-owner", "bob"])
649 assert result.exit_code != 2, f"'transfer-ownership' is not a recognised subcommand: {result.output}"
650
651 def test_repo_transfer_subcommand_removed(self, repo: pathlib.Path) -> None:
652 """'transfer' must no longer be accepted."""
653 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
654 result = runner.invoke(cli, ["hub", "repo", "transfer", "--new-owner", "bob"])
655 assert result.exit_code != 0
656
657
658 # ---------------------------------------------------------------------------
659 # muse hub collaborator update-permission (renamed from "update")
660 # ---------------------------------------------------------------------------
661
662
663 class TestHubCollaboratorUpdatePermissionCommand:
664 """'muse hub collaborator update-permission' is the canonical verb β€” 'update' is gone."""
665
666 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
667 set_hub_url(hub_url, repo)
668 identity = IdentityEntry(
669 type="human",
670 handle="gabriel",
671 algorithm="ed25519",
672 fingerprint="deadbeef",
673 )
674 save_identity(hub_url, identity)
675
676 def test_collaborator_update_permission_subcommand_exists(self, repo: pathlib.Path) -> None:
677 """'update-permission' must be a valid collaborator subcommand β€” exit code must not be 2."""
678 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
679 mock_resp = {"handle": "carol", "permission": "admin"}
680 with unittest.mock.patch(
681 "muse.cli.commands.hub.collaborators._hub_api", return_value=mock_resp
682 ):
683 result = runner.invoke(
684 cli, ["hub", "collaborator", "update-permission", "carol", "--permission", "admin"]
685 )
686 assert result.exit_code != 2, f"'update-permission' is not a recognised subcommand: {result.output}"
687
688 def test_collaborator_update_subcommand_removed(self, repo: pathlib.Path) -> None:
689 """'update' must no longer be accepted as a collaborator subcommand."""
690 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
691 result = runner.invoke(
692 cli, ["hub", "collaborator", "update", "carol", "--permission", "admin"]
693 )
694 assert result.exit_code != 0
695
696
697 # ---------------------------------------------------------------------------
698 # muse hub repo list
699 # ---------------------------------------------------------------------------
700
701
702 class TestHubRepoListCommand:
703 """'muse hub repo list' lists repos for the authenticated user."""
704
705 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
706 set_hub_url(hub_url, repo)
707 identity = IdentityEntry(
708 type="human",
709 handle="gabriel",
710 algorithm="ed25519",
711 fingerprint="deadbeef",
712 )
713 save_identity(hub_url, identity)
714
715 def test_repo_list_subcommand_exists(self, repo: pathlib.Path) -> None:
716 """'list' must be a valid repo subcommand β€” exit code must not be 2."""
717 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
718 mock_resp = {
719 "total": 1,
720 "nextCursor": None,
721 "repos": [
722 {
723 "repoId": "abc",
724 "name": "my-repo",
725 "owner": "gabriel",
726 "slug": "my-repo",
727 "visibility": "public",
728 "description": "Test repo",
729 "tags": [],
730 "defaultBranch": "main",
731 "createdAt": "2026-01-01T00:00:00Z",
732 "pushedAt": "",
733 }
734 ],
735 }
736 with unittest.mock.patch.multiple(
737 "muse.cli.commands.hub",
738 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
739 _get_hub_and_identity=unittest.mock.MagicMock(
740 return_value=("https://localhost:1337", unittest.mock.MagicMock())
741 ),
742 ):
743 result = runner.invoke(cli, ["hub", "repo", "list"])
744 assert result.exit_code != 2, f"'list' is not a recognised subcommand: {result.output}"
745
746 def test_repo_list_json_output_structure(self, repo: pathlib.Path) -> None:
747 """--json emits total, next_cursor, and repos list to stdout."""
748 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
749 mock_resp = {
750 "total": 2,
751 "nextCursor": None,
752 "repos": [
753 {
754 "repoId": "id1",
755 "name": "alpha",
756 "owner": "gabriel",
757 "slug": "alpha",
758 "visibility": "public",
759 "description": "",
760 "tags": [],
761 "defaultBranch": "main",
762 "createdAt": "2026-01-01T00:00:00Z",
763 "pushedAt": "",
764 },
765 {
766 "repoId": "id2",
767 "name": "beta",
768 "owner": "gabriel",
769 "slug": "beta",
770 "visibility": "private",
771 "description": "private repo",
772 "tags": ["music"],
773 "defaultBranch": "dev",
774 "createdAt": "2026-01-02T00:00:00Z",
775 "pushedAt": "",
776 },
777 ],
778 }
779 with unittest.mock.patch.multiple(
780 "muse.cli.commands.hub",
781 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
782 _get_hub_and_identity=unittest.mock.MagicMock(
783 return_value=("https://localhost:1337", unittest.mock.MagicMock())
784 ),
785 ):
786 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
787
788 assert result.exit_code == 0, result.output
789 out = json.loads(result.output)
790 assert out["total"] == 2
791 assert out["next_cursor"] is None
792 assert len(out["repos"]) == 2
793 slugs = [r["slug"] for r in out["repos"]]
794 assert "alpha" in slugs
795 assert "beta" in slugs
796
797 def test_repo_list_json_fields_present(self, repo: pathlib.Path) -> None:
798 """Each repo in --json output has all documented fields."""
799 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
800 mock_resp = {
801 "total": 1,
802 "nextCursor": None,
803 "repos": [
804 {
805 "repoId": "abc",
806 "name": "check-fields",
807 "owner": "gabriel",
808 "slug": "check-fields",
809 "visibility": "public",
810 "description": "desc",
811 "tags": ["a", "b"],
812 "defaultBranch": "main",
813 "createdAt": "2026-01-01T00:00:00Z",
814 "pushedAt": "2026-03-01T00:00:00Z",
815 }
816 ],
817 }
818 with unittest.mock.patch.multiple(
819 "muse.cli.commands.hub",
820 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
821 _get_hub_and_identity=unittest.mock.MagicMock(
822 return_value=("https://localhost:1337", unittest.mock.MagicMock())
823 ),
824 ):
825 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
826
827 assert result.exit_code == 0
828 repos = json.loads(result.output)["repos"]
829 assert len(repos) == 1
830 for field in ("repo_id", "name", "owner", "slug", "visibility",
831 "description", "tags", "default_branch", "created_at", "pushed_at"):
832 assert field in repos[0], f"missing field: {field}"
833
834 def test_repo_list_passes_limit_to_api(self, repo: pathlib.Path) -> None:
835 """--limit is forwarded as a query parameter."""
836 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
837 captured: list[str] = []
838
839 def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kwargs: str) -> MsgpackDict:
840 captured.append(path)
841 return {"total": 0, "nextCursor": None, "repos": []}
842
843 with unittest.mock.patch.multiple(
844 "muse.cli.commands.hub",
845 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
846 _get_hub_and_identity=unittest.mock.MagicMock(
847 return_value=("https://localhost:1337", unittest.mock.MagicMock())
848 ),
849 ):
850 runner.invoke(cli, ["hub", "repo", "list", "--limit", "42", "--json"])
851
852 assert captured, "no API call made"
853 assert "limit=42" in captured[0]
854
855 def test_repo_list_empty_prints_no_repos(self, repo: pathlib.Path) -> None:
856 """Empty repo list exits 0 and does not crash."""
857 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
858 mock_resp = {"total": 0, "nextCursor": None, "repos": []}
859 with unittest.mock.patch.multiple(
860 "muse.cli.commands.hub",
861 _hub_api=unittest.mock.MagicMock(return_value=mock_resp),
862 _get_hub_and_identity=unittest.mock.MagicMock(
863 return_value=("https://localhost:1337", unittest.mock.MagicMock())
864 ),
865 ):
866 result = runner.invoke(cli, ["hub", "repo", "list"])
867 assert result.exit_code == 0
868
869
870 # ---------------------------------------------------------------------------
871 # muse hub repo read
872 # ---------------------------------------------------------------------------
873
874
875 class TestHubRepoReadCommand:
876 """'muse hub repo read' fetches metadata for a single repo."""
877
878 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
879 set_hub_url(hub_url, repo)
880 identity = IdentityEntry(
881 type="human",
882 handle="gabriel",
883 algorithm="ed25519",
884 fingerprint="deadbeef",
885 )
886 save_identity(hub_url, identity)
887
888 _MOCK_REPO = {
889 "repoId": "abc123",
890 "name": "jazz-standards",
891 "owner": "gabriel",
892 "slug": "jazz-standards",
893 "visibility": "public",
894 "description": "A collection of jazz standards",
895 "tags": ["music", "jazz"],
896 "defaultBranch": "main",
897 "cloneUrl": "https://localhost:1337/gabriel/jazz-standards",
898 "createdAt": "2026-01-01T00:00:00Z",
899 "updatedAt": "2026-02-01T00:00:00Z",
900 "pushedAt": "2026-03-01T00:00:00Z",
901 }
902
903 def test_repo_read_subcommand_exists(self, repo: pathlib.Path) -> None:
904 """'read' must be a valid repo subcommand β€” exit code must not be 2."""
905 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
906 with unittest.mock.patch.multiple(
907 "muse.cli.commands.hub",
908 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
909 _get_hub_and_identity=unittest.mock.MagicMock(
910 return_value=("https://localhost:1337", unittest.mock.MagicMock())
911 ),
912 ):
913 result = runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards"])
914 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
915
916 def test_repo_read_json_output_structure(self, repo: pathlib.Path) -> None:
917 """--json emits all documented repo fields to stdout."""
918 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
919 with unittest.mock.patch.multiple(
920 "muse.cli.commands.hub",
921 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
922 _get_hub_and_identity=unittest.mock.MagicMock(
923 return_value=("https://localhost:1337", unittest.mock.MagicMock())
924 ),
925 ):
926 result = runner.invoke(
927 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
928 )
929
930 assert result.exit_code == 0, result.output
931 out = json.loads(result.output)
932 assert out["repo_id"] == "abc123"
933 assert out["slug"] == "jazz-standards"
934 assert out["owner"] == "gabriel"
935 assert out["visibility"] == "public"
936
937 def test_repo_read_json_fields_present(self, repo: pathlib.Path) -> None:
938 """All documented fields are present in --json output."""
939 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
940 with unittest.mock.patch.multiple(
941 "muse.cli.commands.hub",
942 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
943 _get_hub_and_identity=unittest.mock.MagicMock(
944 return_value=("https://localhost:1337", unittest.mock.MagicMock())
945 ),
946 ):
947 result = runner.invoke(
948 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
949 )
950
951 assert result.exit_code == 0
952 out = json.loads(result.output)
953 for field in ("repo_id", "name", "owner", "slug", "visibility",
954 "description", "tags", "default_branch",
955 "clone_url", "created_at", "updated_at", "pushed_at"):
956 assert field in out, f"missing field: {field}"
957
958 def test_repo_read_uses_owner_slug_url(self, repo: pathlib.Path) -> None:
959 """OWNER/SLUG argument resolves via /api/{owner}/{slug} not /api/repos/{id}."""
960 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
961 captured: list[str] = []
962
963 def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kwargs: str) -> MsgpackDict:
964 captured.append(path)
965 return self._MOCK_REPO
966
967 with unittest.mock.patch.multiple(
968 "muse.cli.commands.hub",
969 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
970 _get_hub_and_identity=unittest.mock.MagicMock(
971 return_value=("https://localhost:1337", unittest.mock.MagicMock())
972 ),
973 ):
974 runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"])
975
976 assert captured, "no API call made"
977 assert "/api/gabriel/jazz-standards" in captured[0]
978
979 def test_repo_read_tags_preserved(self, repo: pathlib.Path) -> None:
980 """Tags list is preserved intact in JSON output."""
981 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
982 with unittest.mock.patch.multiple(
983 "muse.cli.commands.hub",
984 _hub_api=unittest.mock.MagicMock(return_value=self._MOCK_REPO),
985 _get_hub_and_identity=unittest.mock.MagicMock(
986 return_value=("https://localhost:1337", unittest.mock.MagicMock())
987 ),
988 ):
989 result = runner.invoke(
990 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
991 )
992
993 assert result.exit_code == 0
994 out = json.loads(result.output)
995 assert out["tags"] == ["music", "jazz"]
996
997
998 class TestHubRepoDeleteCommand:
999 """Tests for run_repo_delete with the new TARGET argument."""
1000
1001 def test_delete_by_repo_id_calls_correct_endpoint(self) -> None:
1002 """run_repo_delete with a repo_id target calls DELETE /api/repos/{repo_id}."""
1003 import argparse
1004 import unittest.mock as mock
1005 from muse.cli.commands.hub.repos import run_repo_delete
1006
1007 repo_id = fake_id("hub-repo-delete-target")
1008
1009 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
1010 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1011 return_value=("https://localhost:1337", mock.MagicMock())):
1012 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
1013 run_repo_delete(args)
1014
1015 calls = [str(c) for c in m_api.call_args_list]
1016 assert any(f"/api/repos/{repo_id}" in c for c in calls), (
1017 f"Expected DELETE /api/repos/{repo_id}, got: {calls}"
1018 )
1019
1020 def test_delete_by_owner_slug_resolves_then_deletes(self) -> None:
1021 """run_repo_delete with OWNER/SLUG fetches repo_id then DELETEs it."""
1022 import argparse
1023 import unittest.mock as mock
1024 from muse.cli.commands.hub.repos import run_repo_delete
1025
1026 resolved_id = fake_id("hub-repo-resolved-id")
1027 get_resp = {"repoId": resolved_id, "name": "my-repo", "owner": "gabriel"}
1028
1029 with mock.patch("muse.cli.commands.hub._hub_api",
1030 side_effect=[get_resp, {}]) as m_api, \
1031 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1032 return_value=("https://localhost:1337", mock.MagicMock())):
1033 args = argparse.Namespace(target="gabriel/my-repo", yes=True,
1034 hub=None, json_output=True)
1035 run_repo_delete(args)
1036
1037 calls = m_api.call_args_list
1038 assert len(calls) == 2
1039 # First: GET to resolve owner/slug
1040 assert calls[0].args[2] == "GET"
1041 assert "/api/gabriel/my-repo" in calls[0].args[3]
1042 # Second: DELETE with resolved repo_id
1043 assert calls[1].args[2] == "DELETE"
1044 assert f"/api/repos/{resolved_id}" in calls[1].args[3]
1045
1046 def test_delete_without_yes_exits_nonzero_and_skips_api(self) -> None:
1047 """Without --yes, exits non-zero and never calls the API."""
1048 import argparse
1049 import pytest
1050 import unittest.mock as mock
1051 from muse.cli.commands.hub.repos import run_repo_delete
1052
1053 with mock.patch("muse.cli.commands.hub._hub_api") as m_api, \
1054 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1055 return_value=("https://localhost:1337", mock.MagicMock())):
1056 args = argparse.Namespace(target="gabriel/my-repo", yes=False,
1057 hub=None, json_output=False)
1058 with pytest.raises(SystemExit) as exc_info:
1059 run_repo_delete(args)
1060
1061 assert exc_info.value.code != 0
1062 m_api.assert_not_called()
1063
1064 def test_delete_no_target_falls_back_to_config_resolution(self) -> None:
1065 """When target is None, repo_id is resolved from the current directory config."""
1066 import argparse
1067 import unittest.mock as mock
1068 from muse.cli.commands.hub.repos import run_repo_delete
1069
1070 config_repo_id = "c5f6e7a8-0000-0000-0000-000000000003"
1071
1072 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
1073 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1074 return_value=("https://localhost:1337", mock.MagicMock())), \
1075 mock.patch("muse.cli.commands.hub._resolve_repo_id",
1076 return_value=config_repo_id):
1077 args = argparse.Namespace(target=None, yes=True, hub=None, json_output=True)
1078 run_repo_delete(args)
1079
1080 calls = [str(c) for c in m_api.call_args_list]
1081 assert any(f"/api/repos/{config_repo_id}" in c for c in calls), (
1082 f"Expected DELETE using config repo_id, got: {calls}"
1083 )
1084
1085 def test_delete_json_output_emits_structured_result(self) -> None:
1086 """--json flag emits {deleted: true, repo_id: ...} to stdout."""
1087 import argparse
1088 import io
1089 import json as json_mod
1090 import sys
1091 import unittest.mock as mock
1092 from muse.cli.commands.hub.repos import run_repo_delete
1093
1094 repo_id = fake_id("hub-repo-delete-json-output")
1095
1096 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}), \
1097 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1098 return_value=("https://localhost:1337", mock.MagicMock())):
1099 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
1100 captured = io.StringIO()
1101 with mock.patch("sys.stdout", captured):
1102 run_repo_delete(args)
1103
1104 output = json_mod.loads(captured.getvalue())
1105 assert output["deleted"] is True
1106 assert output["repo_id"] == repo_id
1107
1108
1109 # ---------------------------------------------------------------------------
1110 # Agent ergonomics β€” absorb git/GitHub muscle-memory flags
1111 # ---------------------------------------------------------------------------
1112
1113
1114 def _mock_create_resp() -> MsgpackDict:
1115 return {
1116 "repoId": "abc-123",
1117 "name": "my-repo",
1118 "owner": "gabriel",
1119 "slug": "my-repo",
1120 "visibility": "public",
1121 "description": "",
1122 "cloneUrl": "https://staging.musehub.ai/gabriel/my-repo",
1123 "tags": [],
1124 "createdAt": "2026-04-22T00:00:00Z",
1125 }
1126
1127
1128 class TestRepoCreateVisibilityAlias:
1129 """``hub repo create --visibility public|private`` must work as an alias
1130 for the canonical ``--private`` boolean flag.
1131
1132 Agents trained on GitHub CLI reach for ``--visibility`` reflexively.
1133 Rejecting it with an argparse "unrecognized arguments" error wastes a
1134 round-trip and forces the agent to re-read docs. Absorbing the flag
1135 silently maps it to the right internal value.
1136 """
1137
1138 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
1139 set_hub_url(hub_url, repo)
1140 identity = IdentityEntry(
1141 type="human",
1142 handle="gabriel",
1143 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
1144 algorithm="ed25519",
1145 fingerprint=long_id("a" * 64),
1146 )
1147 save_identity(hub_url, identity)
1148
1149 def test_visibility_public_is_accepted(self, repo: pathlib.Path) -> None:
1150 """``--visibility public`` must not be rejected by the parser."""
1151 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1152 with unittest.mock.patch.multiple(
1153 "muse.cli.commands.hub",
1154 _hub_api=unittest.mock.MagicMock(return_value=_mock_create_resp()),
1155 _get_hub_and_identity=unittest.mock.MagicMock(
1156 return_value=("https://localhost:1337", {"handle": "gabriel"})
1157 ),
1158 ):
1159 result = runner.invoke(
1160 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--json"]
1161 )
1162 assert result.exit_code != 2, (
1163 "--visibility public must not produce 'unrecognized arguments'; "
1164 f"got: {result.output}"
1165 )
1166 assert result.exit_code == 0, result.output
1167
1168 def test_visibility_private_maps_to_private(self, repo: pathlib.Path) -> None:
1169 """``--visibility private`` must create a private repo (same as ``--private``)."""
1170 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1171 captured_payload: list[MsgpackDict] = []
1172
1173 def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: MsgpackDict | None = None, **kw: str) -> MsgpackDict:
1174 if body:
1175 captured_payload.append(body)
1176 return _mock_create_resp()
1177
1178 with unittest.mock.patch.multiple(
1179 "muse.cli.commands.hub",
1180 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
1181 _get_hub_and_identity=unittest.mock.MagicMock(
1182 return_value=("https://localhost:1337", {"handle": "gabriel"})
1183 ),
1184 ):
1185 result = runner.invoke(
1186 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "private", "--json"]
1187 )
1188 assert result.exit_code == 0, result.output
1189 assert captured_payload, "no API call was made"
1190 assert captured_payload[0]["visibility"] == "private", (
1191 "--visibility private must send visibility=private to the API"
1192 )
1193
1194 def test_visibility_public_sends_public(self, repo: pathlib.Path) -> None:
1195 """``--visibility public`` must send visibility=public to the API."""
1196 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1197 captured_payload: list[MsgpackDict] = []
1198
1199 def fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, body: MsgpackDict | None = None, **kw: str) -> MsgpackDict:
1200 if body:
1201 captured_payload.append(body)
1202 return _mock_create_resp()
1203
1204 with unittest.mock.patch.multiple(
1205 "muse.cli.commands.hub",
1206 _hub_api=unittest.mock.MagicMock(side_effect=fake_api),
1207 _get_hub_and_identity=unittest.mock.MagicMock(
1208 return_value=("https://localhost:1337", {"handle": "gabriel"})
1209 ),
1210 ):
1211 result = runner.invoke(
1212 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--json"]
1213 )
1214 assert result.exit_code == 0, result.output
1215 assert captured_payload[0]["visibility"] == "public"
1216
1217 def test_visibility_invalid_value_exits_nonzero(self, repo: pathlib.Path) -> None:
1218 """``--visibility protected`` (invalid) must fail with a clear error."""
1219 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1220 result = runner.invoke(
1221 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "protected"]
1222 )
1223 assert result.exit_code != 0, "invalid --visibility value must not succeed"
1224 combined = (result.output or "") + (result.stderr or "")
1225 assert "protected" in combined or "public" in combined or "private" in combined, (
1226 "error must mention the invalid value or valid choices"
1227 )
1228
1229 def test_visibility_conflicts_with_private_flag(self, repo: pathlib.Path) -> None:
1230 """``--visibility public --private`` is contradictory and must be rejected."""
1231 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1232 result = runner.invoke(
1233 cli, ["hub", "repo", "create", "--name", "my-repo", "--visibility", "public", "--private"]
1234 )
1235 assert result.exit_code != 0, (
1236 "--visibility public combined with --private is contradictory and must fail"
1237 )
1238
1239
1240 class TestRepoListOwnerFlag:
1241 """``hub repo list --owner`` must give an actionable error, not a generic
1242 argparse rejection.
1243
1244 Agents trained on GitHub CLI reach for ``--owner gabriel`` reflexively.
1245 The correct Muse pattern is to fetch all repos and filter in Python.
1246 The error must explain this and show the exact filter command.
1247 """
1248
1249 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
1250 set_hub_url(hub_url, repo)
1251 identity = IdentityEntry(
1252 type="human",
1253 handle="gabriel",
1254 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
1255 algorithm="ed25519",
1256 fingerprint=long_id("a" * 64),
1257 )
1258 save_identity(hub_url, identity)
1259
1260 def test_owner_flag_is_accepted_by_parser(self, repo: pathlib.Path) -> None:
1261 """``--owner`` must not produce argparse's generic 'unrecognized arguments' error."""
1262 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1263 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1264 assert result.exit_code != 2, (
1265 "--owner must not produce exit code 2 (unrecognized argument); "
1266 f"got output: {result.output}"
1267 )
1268
1269 def test_owner_flag_exits_with_helpful_error(self, repo: pathlib.Path) -> None:
1270 """``--owner`` must exit non-zero with a message explaining the filter pattern."""
1271 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1272 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1273 assert result.exit_code != 0, "--owner must exit non-zero (it is not a real filter)"
1274 combined = (result.output or "") + (result.stderr or "")
1275 assert "owner" in combined.lower(), "error must mention 'owner'"
1276
1277 def test_owner_error_suggests_pipe_pattern(self, repo: pathlib.Path) -> None:
1278 """The error message must show the python-pipe filter pattern."""
1279 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1280 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1281 combined = (result.output or "") + (result.stderr or "")
1282 assert "python" in combined.lower() or "json" in combined.lower(), (
1283 "error must suggest filtering via --json | python3"
1284 )
1285
1286 def test_owner_error_names_the_owner_value(self, repo: pathlib.Path) -> None:
1287 """The error must echo back the owner value so the agent knows it was received."""
1288 self._setup_auth(repo, "https://localhost:1337/gabriel/muse")
1289 result = runner.invoke(cli, ["hub", "repo", "list", "--owner", "gabriel"])
1290 combined = (result.output or "") + (result.stderr or "")
1291 assert "gabriel" in combined, "error must echo back the owner value"
1292
1293
1294 # ---------------------------------------------------------------------------
1295 # muse hub proposal update
1296 # ---------------------------------------------------------------------------
1297
1298 type _PropData = dict[str, str | int | float | bool | None]
1299 _KwVal = str | int | float | bool | None
1300
1301
1302 class TestHubProposalUpdateCommand:
1303 """'muse hub proposal update' β€” partial PATCH for title/body/type/strategy."""
1304
1305 _HUB = "https://localhost:1337/gabriel/muse"
1306
1307 def _setup_auth(self, repo: pathlib.Path) -> None:
1308 set_hub_url(self._HUB, repo)
1309 identity = IdentityEntry(
1310 type="human",
1311 handle="gabriel",
1312 algorithm="ed25519",
1313 fingerprint="deadbeef",
1314 )
1315 save_identity(self._HUB, identity)
1316
1317 def _mock_proposal(self, **overrides: _KwVal) -> _PropData:
1318 base: _PropData = {
1319 "proposalId": "sha256:" + "a" * 64,
1320 "number": 1,
1321 "title": "Original title",
1322 "body": "Original body.",
1323 "state": "open",
1324 "proposalType": "state_merge",
1325 "mergeStrategy": "state_overlay",
1326 "fromBranch": "feat/x",
1327 "toBranch": "dev",
1328 "author": "gabriel",
1329 "createdAt": "2026-05-01T00:00:00Z",
1330 }
1331 return {**base, **overrides}
1332
1333 def _patches(self, api_return: _PropData, calls: list[tuple[str, ...]] | None = None) -> "unittest.mock._patch": # type: ignore[name-defined]
1334 import unittest.mock as mock
1335
1336 def _fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kw: _KwVal) -> _PropData:
1337 if calls is not None:
1338 kw_body = kw.get("body", {})
1339 calls.append((method, path, kw_body))
1340 return api_return
1341
1342 return mock.patch.multiple(
1343 "muse.cli.commands.hub.proposals",
1344 _hub_api=mock.MagicMock(side_effect=_fake_api),
1345 _get_hub_and_identity=mock.MagicMock(
1346 return_value=(self._HUB.rsplit("/", 2)[0], mock.MagicMock())
1347 ),
1348 _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"),
1349 _resolve_proposal_id=mock.MagicMock(side_effect=lambda *a: a[3]),
1350 )
1351
1352 # ── T11.1 β€” subcommand exists ─────────────────────────────────────────────
1353
1354 def test_update_subcommand_exists(self, repo: pathlib.Path) -> None:
1355 """'update' must be a valid subcommand β€” exit code must not be 2."""
1356 self._setup_auth(repo)
1357 with self._patches(self._mock_proposal()):
1358 result = runner.invoke(
1359 cli, ["hub", "proposal", "update", "abc123", "--title", "New title"]
1360 )
1361 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
1362
1363 # ── T11.2 β€” update title ──────────────────────────────────────────────────
1364
1365 def test_update_title_calls_patch(self, repo: pathlib.Path) -> None:
1366 """--title must issue a PATCH request with the new title."""
1367 self._setup_auth(repo)
1368 calls: list[tuple[str, ...]] = []
1369 updated = self._mock_proposal(title="Shiny new title")
1370 with self._patches(updated, calls):
1371 result = runner.invoke(
1372 cli, ["hub", "proposal", "update", "abc123", "--title", "Shiny new title"]
1373 )
1374 assert result.exit_code == 0, result.output
1375 methods = [m for m, _, _ in calls]
1376 assert "PATCH" in methods, f"PATCH not called; calls: {calls}"
1377 bodies = [b for _, _, b in calls if isinstance(b, dict)]
1378 assert any("title" in b for b in bodies), f"body missing 'title'; calls: {calls}"
1379
1380 # ── T11.3 β€” update body ───────────────────────────────────────────────────
1381
1382 def test_update_body_calls_patch(self, repo: pathlib.Path) -> None:
1383 """--body must issue a PATCH request with the new body."""
1384 self._setup_auth(repo)
1385 calls: list[tuple[str, ...]] = []
1386 updated = self._mock_proposal(body="Updated description.")
1387 with self._patches(updated, calls):
1388 result = runner.invoke(
1389 cli, ["hub", "proposal", "update", "abc123", "--body", "Updated description."]
1390 )
1391 assert result.exit_code == 0, result.output
1392 bodies = [b for _, _, b in calls if isinstance(b, dict)]
1393 assert any("body" in b for b in bodies), f"body missing 'body' key; calls: {calls}"
1394
1395 # ── T11.4 β€” update type ───────────────────────────────────────────────────
1396
1397 def test_update_type_calls_patch(self, repo: pathlib.Path) -> None:
1398 """--type must issue a PATCH with proposal_type."""
1399 self._setup_auth(repo)
1400 calls: list[tuple[str, ...]] = []
1401 updated = self._mock_proposal(proposalType="canonical_release")
1402 with self._patches(updated, calls):
1403 result = runner.invoke(
1404 cli, ["hub", "proposal", "update", "abc123", "--type", "canonical_release"]
1405 )
1406 assert result.exit_code == 0, result.output
1407 bodies = [b for _, _, b in calls if isinstance(b, dict)]
1408 assert any("proposal_type" in b for b in bodies), f"body missing 'proposal_type'; calls: {calls}"
1409
1410 # ── T11.5 β€” update strategy ───────────────────────────────────────────────
1411
1412 def test_update_strategy_calls_patch(self, repo: pathlib.Path) -> None:
1413 """--strategy must issue a PATCH with merge_strategy."""
1414 self._setup_auth(repo)
1415 calls: list[tuple[str, ...]] = []
1416 updated = self._mock_proposal(mergeStrategy="state_rebase")
1417 with self._patches(updated, calls):
1418 result = runner.invoke(
1419 cli, ["hub", "proposal", "update", "abc123", "--strategy", "state_rebase"]
1420 )
1421 assert result.exit_code == 0, result.output
1422 bodies = [b for _, _, b in calls if isinstance(b, dict)]
1423 assert any("merge_strategy" in b for b in bodies), f"body missing 'merge_strategy'; calls: {calls}"
1424
1425 # ── T11.6 β€” no flags β†’ nonzero exit ──────────────────────────────────────
1426
1427 def test_no_flags_exits_nonzero(self, repo: pathlib.Path) -> None:
1428 """Supplying no update flags must exit nonzero β€” nothing to patch."""
1429 self._setup_auth(repo)
1430 with self._patches(self._mock_proposal()):
1431 result = runner.invoke(cli, ["hub", "proposal", "update", "abc123"])
1432 assert result.exit_code != 0, "expected nonzero exit when no update flags supplied"
1433
1434 # ── T11.7 β€” json output ───────────────────────────────────────────────────
1435
1436 def test_json_output_contains_proposal_id(self, repo: pathlib.Path) -> None:
1437 """--json must emit a JSON object containing the updated proposal data."""
1438 self._setup_auth(repo)
1439 updated = self._mock_proposal(title="JSON title")
1440 with self._patches(updated):
1441 result = runner.invoke(
1442 cli, ["hub", "proposal", "update", "abc123", "--title", "JSON title", "--json"]
1443 )
1444 assert result.exit_code == 0, result.output
1445 data = json.loads(result.output)
1446 assert "proposalId" in data or "proposal_id" in data, (
1447 f"JSON output must contain proposalId; got keys: {list(data.keys())}"
1448 )
1449
1450 # ── T11.8 β€” body-file ─────────────────────────────────────────────────────
1451
1452 def test_body_file_reads_from_disk(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
1453 """--body-file must read the body from disk and send it in the PATCH."""
1454 self._setup_auth(repo)
1455 body_path = tmp_path / "body.md"
1456 body_path.write_text("Body from file.")
1457 calls: list[tuple[str, ...]] = []
1458 updated = self._mock_proposal(body="Body from file.")
1459 with self._patches(updated, calls):
1460 result = runner.invoke(
1461 cli, ["hub", "proposal", "update", "abc123", "--body-file", str(body_path)]
1462 )
1463 assert result.exit_code == 0, result.output
1464 bodies = [b for _, _, b in calls if isinstance(b, dict)]
1465 assert any(b.get("body") == "Body from file." for b in bodies), (
1466 f"PATCH body must contain file content; got: {bodies}"
1467 )
1468
1469 # ── T11.9 β€” api error propagates ─────────────────────────────────────────
1470
1471 def test_api_error_exits_code_3(self, repo: pathlib.Path) -> None:
1472 """An API / network error must exit with code 3."""
1473 self._setup_auth(repo)
1474 import unittest.mock as mock
1475
1476 def _raise(*a: str, **kw: _KwVal) -> None:
1477 raise RuntimeError("network failure")
1478
1479 with mock.patch.multiple(
1480 "muse.cli.commands.hub.proposals",
1481 _hub_api=mock.MagicMock(side_effect=_raise),
1482 _get_hub_and_identity=mock.MagicMock(
1483 return_value=(self._HUB.rsplit("/", 2)[0], mock.MagicMock())
1484 ),
1485 _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"),
1486 _resolve_proposal_id=mock.MagicMock(side_effect=lambda *a: a[3]),
1487 ):
1488 result = runner.invoke(
1489 cli, ["hub", "proposal", "update", "abc123", "--title", "X"]
1490 )
1491 assert result.exit_code == 3, f"expected exit 3 on API error; got {result.exit_code}: {result.output}"
1492
1493
1494 # ---------------------------------------------------------------------------
1495 # muse hub proposal close
1496 # ---------------------------------------------------------------------------
1497
1498
1499 class TestHubProposalCloseCommand:
1500 """'muse hub proposal close' β€” POST .../close endpoint."""
1501
1502 _HUB = "https://localhost:1337/gabriel/muse"
1503
1504 def _setup_auth(self, repo: pathlib.Path) -> None:
1505 set_hub_url(self._HUB, repo)
1506 identity = IdentityEntry(
1507 type="human",
1508 handle="gabriel",
1509 algorithm="ed25519",
1510 fingerprint="deadbeef",
1511 )
1512 save_identity(self._HUB, identity)
1513
1514 def _mock_proposal(self, **overrides: _KwVal) -> _PropData:
1515 base: _PropData = {
1516 "proposalId": "sha256:" + "a" * 64,
1517 "number": 1,
1518 "title": "Close me",
1519 "state": "closed",
1520 "fromBranch": "feat/x",
1521 "toBranch": "dev",
1522 "author": "gabriel",
1523 "createdAt": "2026-05-09T00:00:00Z",
1524 }
1525 return {**base, **overrides}
1526
1527 def _patches(self, api_return: _PropData, calls: list[tuple[str, ...]] | None = None) -> "unittest.mock._patch": # type: ignore[name-defined]
1528 import unittest.mock as mock
1529
1530 def _fake_api(hub_url: str, identity: IdentityEntry, method: str, path: str, **kw: _KwVal) -> _PropData:
1531 if calls is not None:
1532 calls.append((method, path))
1533 return api_return
1534
1535 return mock.patch.multiple(
1536 "muse.cli.commands.hub.proposals",
1537 _hub_api=mock.MagicMock(side_effect=_fake_api),
1538 _get_hub_and_identity=mock.MagicMock(
1539 return_value=(self._HUB.rsplit("/", 2)[0], mock.MagicMock())
1540 ),
1541 _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"),
1542 _resolve_proposal_id=mock.MagicMock(side_effect=lambda *a: a[3]),
1543 )
1544
1545 def test_close_subcommand_exists(self, repo: pathlib.Path) -> None:
1546 """'close' must be a valid subcommand β€” exit code must not be 2."""
1547 self._setup_auth(repo)
1548 with self._patches(self._mock_proposal()):
1549 result = runner.invoke(cli, ["hub", "proposal", "close", "abc123"])
1550 assert result.exit_code != 2, f"'close' is not a recognised subcommand: {result.output}"
1551
1552 def test_close_calls_post_to_close_endpoint(self, repo: pathlib.Path) -> None:
1553 """'close' must issue POST to .../proposals/{id}/close."""
1554 self._setup_auth(repo)
1555 calls: list[tuple[str, ...]] = []
1556 with self._patches(self._mock_proposal(), calls):
1557 result = runner.invoke(cli, ["hub", "proposal", "close", "abc123"])
1558 assert result.exit_code == 0, result.output
1559 assert any("POST" == m and "/close" in p for m, p in calls), (
1560 f"Expected POST to /close endpoint; got calls: {calls}"
1561 )
1562
1563 def test_close_json_output_contains_proposal_id(self, repo: pathlib.Path) -> None:
1564 """--json must emit JSON with proposalId and state=closed."""
1565 self._setup_auth(repo)
1566 with self._patches(self._mock_proposal()):
1567 result = runner.invoke(cli, ["hub", "proposal", "close", "abc123", "--json"])
1568 assert result.exit_code == 0, result.output
1569 data = json.loads(result.output)
1570 assert "proposalId" in data or "proposal_id" in data, (
1571 f"JSON output must contain proposalId; got keys: {list(data.keys())}"
1572 )
1573 assert data.get("state") == "closed", f"state must be 'closed'; got: {data.get('state')}"
1574
1575 def test_close_text_output_confirms_closed(self, repo: pathlib.Path) -> None:
1576 """Text output (stderr) must confirm the proposal was closed."""
1577 self._setup_auth(repo)
1578 with self._patches(self._mock_proposal()):
1579 result = runner.invoke(cli, ["hub", "proposal", "close", "abc123"])
1580 assert result.exit_code == 0, result.output
1581 combined = result.output + (result.stderr if hasattr(result, "stderr") else "")
1582 assert "closed" in combined.lower(), (
1583 f"Expected 'closed' confirmation in output; got: {combined!r}"
1584 )
1585
1586 def test_close_requires_proposal_id(self, repo: pathlib.Path) -> None:
1587 """'close' without a proposal_id must exit with code 2."""
1588 self._setup_auth(repo)
1589 result = runner.invoke(cli, ["hub", "proposal", "close"])
1590 assert result.exit_code == 2, (
1591 f"Expected exit 2 (argparse error) without proposal_id; got {result.exit_code}"
1592 )
1593
1594 def test_close_hub_flag_accepted(self, repo: pathlib.Path) -> None:
1595 """--hub override must be accepted without error."""
1596 self._setup_auth(repo)
1597 with self._patches(self._mock_proposal()):
1598 result = runner.invoke(
1599 cli,
1600 ["hub", "proposal", "close", "abc123", "--hub", "https://staging.musehub.ai/gabriel/muse"],
1601 )
1602 assert result.exit_code != 2, f"--hub flag rejected: {result.output}"