gabriel / muse public
test_cli_hub.py python
1,067 lines 46.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 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 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
26 runner = CliRunner()
27
28
29 # ---------------------------------------------------------------------------
30 # Fixture: minimal Muse repo
31 # ---------------------------------------------------------------------------
32
33
34 @pytest.fixture()
35 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
36 """Minimal .muse/ repo; hub tests don't need commits."""
37 muse_dir = tmp_path / ".muse"
38 (muse_dir / "refs" / "heads").mkdir(parents=True)
39 (muse_dir / "objects").mkdir()
40 (muse_dir / "commits").mkdir()
41 (muse_dir / "snapshots").mkdir()
42 (muse_dir / "repo.json").write_text(
43 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"})
44 )
45 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
46 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
47 monkeypatch.chdir(tmp_path)
48 # Redirect the identity store to tmp_path so tests never touch ~/.muse/
49 fake_identity_dir = tmp_path / "fake_home" / ".muse"
50 fake_identity_dir.mkdir(parents=True)
51 fake_identity_file = fake_identity_dir / "identity.toml"
52 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_identity_dir)
53 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_identity_file)
54 return tmp_path
55
56
57 # ---------------------------------------------------------------------------
58 # Unit tests for pure helper functions
59 # ---------------------------------------------------------------------------
60
61
62 class TestNormaliseUrl:
63 def test_bare_hostname_gets_https(self) -> None:
64 assert _normalise_url("musehub.ai") == "https://musehub.ai"
65
66 def test_https_url_unchanged(self) -> None:
67 assert _normalise_url("https://musehub.ai") == "https://musehub.ai"
68
69 def test_trailing_slash_stripped(self) -> None:
70 assert _normalise_url("https://musehub.ai/") == "https://musehub.ai"
71
72 def test_http_url_raises(self) -> None:
73 with pytest.raises(ValueError, match="Insecure"):
74 _normalise_url("http://musehub.ai")
75
76 def test_http_suggests_https(self) -> None:
77 with pytest.raises(ValueError, match="https://"):
78 _normalise_url("http://musehub.ai")
79
80 def test_whitespace_stripped(self) -> None:
81 assert _normalise_url(" https://musehub.ai ") == "https://musehub.ai"
82
83
84 class TestHubHostname:
85 def test_extracts_hostname_from_https_url(self) -> None:
86 assert _hub_hostname("https://musehub.ai/repos/r1") == "musehub.ai"
87
88 def test_bare_hostname(self) -> None:
89 assert _hub_hostname("musehub.ai") == "musehub.ai"
90
91 def test_strips_path(self) -> None:
92 assert _hub_hostname("https://musehub.ai/deep/path") == "musehub.ai"
93
94 def test_preserves_port(self) -> None:
95 assert _hub_hostname("https://musehub.ai:8443") == "musehub.ai:8443"
96
97
98 class TestPingHub:
99 def test_2xx_returns_true(self) -> None:
100 mock_resp = unittest.mock.MagicMock()
101 mock_resp.status = 200
102 mock_resp.__enter__ = lambda s: s
103 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
104 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp):
105 ok, msg = _ping_hub("https://musehub.ai")
106 assert ok is True
107 assert "200" in msg
108
109 def test_5xx_returns_false(self) -> None:
110 mock_resp = unittest.mock.MagicMock()
111 mock_resp.status = 503
112 mock_resp.__enter__ = lambda s: s
113 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
114 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp):
115 ok, msg = _ping_hub("https://musehub.ai")
116 assert ok is False
117
118 def test_http_error_returns_false(self) -> None:
119 err = urllib.error.HTTPError("https://musehub.ai/health", 401, "Unauthorized", {}, io.BytesIO(b"Unauthorized"))
120 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err):
121 ok, msg = _ping_hub("https://musehub.ai")
122 assert ok is False
123 assert "401" in msg
124
125 def test_url_error_returns_false(self) -> None:
126 err = urllib.error.URLError("name resolution failure")
127 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err):
128 ok, msg = _ping_hub("https://musehub.ai")
129 assert ok is False
130
131 def test_timeout_error_returns_false(self) -> None:
132 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=TimeoutError()):
133 ok, msg = _ping_hub("https://musehub.ai")
134 assert ok is False
135 assert "timed out" in msg
136
137 def test_os_error_returns_false(self) -> None:
138 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=OSError("network down")):
139 ok, msg = _ping_hub("https://musehub.ai")
140 assert ok is False
141
142 def test_health_endpoint_used(self) -> None:
143 calls: list[str] = []
144
145 def _fake_open(req: urllib.request.Request, timeout: int = 0) -> urllib.response.addinfourl:
146 calls.append(req.full_url)
147 raise urllib.error.URLError("stop")
148
149 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=_fake_open):
150 _ping_hub("https://musehub.ai")
151 assert calls and calls[0] == "https://musehub.ai/health"
152
153
154 # ---------------------------------------------------------------------------
155 # hub connect
156 # ---------------------------------------------------------------------------
157
158
159 class TestHubConnect:
160 def test_connect_bare_hostname(self, repo: pathlib.Path) -> None:
161 result = runner.invoke(cli, ["hub", "connect", "musehub.ai"])
162 assert result.exit_code == 0
163 assert "Connected" in result.output
164
165 def test_connect_stores_https_url(self, repo: pathlib.Path) -> None:
166 runner.invoke(cli, ["hub", "connect", "musehub.ai"])
167 stored = get_hub_url(repo)
168 assert stored == "https://musehub.ai"
169
170 def test_connect_https_url_directly(self, repo: pathlib.Path) -> None:
171 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
172 assert result.exit_code == 0
173 assert get_hub_url(repo) == "https://musehub.ai"
174
175 def test_connect_http_rejected(self, repo: pathlib.Path) -> None:
176 result = runner.invoke(cli, ["hub", "connect", "http://musehub.ai"])
177 assert result.exit_code != 0
178 assert "Insecure" in result.output or "rejected" in result.output
179
180 def test_connect_warns_on_hub_switch(self, repo: pathlib.Path) -> None:
181 runner.invoke(cli, ["hub", "connect", "https://hub1.example.com"])
182 result = runner.invoke(cli, ["hub", "connect", "https://hub2.example.com"])
183 assert result.exit_code == 0
184 assert "hub1.example.com" in result.output or "Switching" in result.output
185
186 def test_connect_shows_identity_if_already_logged_in(self, repo: pathlib.Path) -> None:
187 entry: IdentityEntry = {"type": "human", "handle": "Alice", "key_path": "/k"}
188 save_identity("https://musehub.ai", entry)
189 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
190 assert result.exit_code == 0
191 assert "Alice" in result.output or "human" in result.output
192
193 def test_connect_prompts_login_when_no_identity(self, repo: pathlib.Path) -> None:
194 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
195 assert result.exit_code == 0
196 assert "muse auth" in result.output
197
198 def test_connect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
199 monkeypatch.chdir(tmp_path)
200 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
201 result = runner.invoke(cli, ["hub", "connect", "https://musehub.ai"])
202 assert result.exit_code != 0
203
204
205 # ---------------------------------------------------------------------------
206 # hub status
207 # ---------------------------------------------------------------------------
208
209
210 class TestHubStatus:
211 def _setup_hub(self, repo: pathlib.Path) -> None:
212 set_hub_url("https://musehub.ai", repo)
213
214 def test_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
215 result = runner.invoke(cli, ["hub", "status"])
216 assert result.exit_code != 0
217
218 def test_hub_url_shown(self, repo: pathlib.Path) -> None:
219 self._setup_hub(repo)
220 result = runner.invoke(cli, ["hub", "status"])
221 assert result.exit_code == 0
222 assert "musehub.ai" in result.output
223
224 def test_not_authenticated_shown(self, repo: pathlib.Path) -> None:
225 self._setup_hub(repo)
226 result = runner.invoke(cli, ["hub", "status"])
227 assert "not authenticated" in result.output or "auth" in result.output
228
229 def test_identity_fields_shown_when_logged_in(self, repo: pathlib.Path) -> None:
230 self._setup_hub(repo)
231 entry: IdentityEntry = {"type": "agent", "handle": "bot", "fingerprint": "agt_001", "key_path": "/k"}
232 save_identity("https://musehub.ai", entry)
233 result = runner.invoke(cli, ["hub", "status"])
234 assert "agent" in result.output
235 assert "bot" in result.output
236
237 def test_json_output_structure(self, repo: pathlib.Path) -> None:
238 self._setup_hub(repo)
239 result = runner.invoke(cli, ["hub", "status", "--json"])
240 assert result.exit_code == 0
241 data = json.loads(result.output)
242 assert "hub_url" in data
243 assert "hostname" in data
244 assert "authenticated" in data
245
246 def test_json_output_with_identity(self, repo: pathlib.Path) -> None:
247 self._setup_hub(repo)
248 entry: IdentityEntry = {"type": "human", "handle": "Alice", "fingerprint": "usr_1", "key_path": "/k"}
249 save_identity("https://musehub.ai", entry)
250 result = runner.invoke(cli, ["hub", "status", "--json"])
251 data = json.loads(result.output)
252 assert data["authenticated"] is True
253 assert data["identity_type"] == "human"
254 assert data["identity_name"] == "Alice"
255
256
257 # ---------------------------------------------------------------------------
258 # hub disconnect
259 # ---------------------------------------------------------------------------
260
261
262 class TestHubDisconnect:
263 def test_disconnect_clears_hub_url(self, repo: pathlib.Path) -> None:
264 set_hub_url("https://musehub.ai", repo)
265 result = runner.invoke(cli, ["hub", "disconnect"])
266 assert result.exit_code == 0
267 assert get_hub_url(repo) is None
268
269 def test_disconnect_shows_hostname(self, repo: pathlib.Path) -> None:
270 set_hub_url("https://musehub.ai", repo)
271 result = runner.invoke(cli, ["hub", "disconnect"])
272 assert "musehub.ai" in result.output
273
274 def test_disconnect_nothing_to_do(self, repo: pathlib.Path) -> None:
275 result = runner.invoke(cli, ["hub", "disconnect"])
276 assert result.exit_code == 0
277 assert "nothing" in result.output.lower() or "No hub" in result.output
278
279 def test_disconnect_preserves_identity(self, repo: pathlib.Path) -> None:
280 """Credentials in identity.toml must survive hub disconnect."""
281 set_hub_url("https://musehub.ai", repo)
282 entry: IdentityEntry = {"type": "human", "handle": "alice", "key_path": "/k"}
283 save_identity("https://musehub.ai", entry)
284 runner.invoke(cli, ["hub", "disconnect"])
285 from muse.core.identity import load_identity
286 assert load_identity("https://musehub.ai") is not None
287
288 def test_disconnect_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
289 monkeypatch.chdir(tmp_path)
290 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
291 result = runner.invoke(cli, ["hub", "disconnect"])
292 assert result.exit_code != 0
293
294
295 # ---------------------------------------------------------------------------
296 # hub ping
297 # ---------------------------------------------------------------------------
298
299
300 class TestHubPing:
301 def _setup_hub(self, repo: pathlib.Path) -> None:
302 set_hub_url("https://musehub.ai", repo)
303
304 def test_ping_success(self, repo: pathlib.Path) -> None:
305 self._setup_hub(repo)
306 mock_resp = unittest.mock.MagicMock()
307 mock_resp.status = 200
308 mock_resp.__enter__ = lambda s: s
309 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
310 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", return_value=mock_resp):
311 result = runner.invoke(cli, ["hub", "ping"])
312 assert result.exit_code == 0
313 assert "200" in result.output or "OK" in result.output.upper()
314
315 def test_ping_failure_exits_nonzero(self, repo: pathlib.Path) -> None:
316 self._setup_hub(repo)
317 err = urllib.error.URLError("no route to host")
318 with unittest.mock.patch("muse.cli.commands.hub._PING_OPENER.open", side_effect=err):
319 result = runner.invoke(cli, ["hub", "ping"])
320 assert result.exit_code != 0
321
322 def test_ping_no_hub_exits_nonzero(self, repo: pathlib.Path) -> None:
323 result = runner.invoke(cli, ["hub", "ping"])
324 assert result.exit_code != 0
325
326 def test_ping_fails_outside_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
327 monkeypatch.chdir(tmp_path)
328 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
329 result = runner.invoke(cli, ["hub", "ping"])
330 assert result.exit_code != 0
331
332
333 # ---------------------------------------------------------------------------
334 # _enrich_hub_url_from_remote
335 # ---------------------------------------------------------------------------
336
337
338 class TestEnrichHubUrlFromRemote:
339 """_enrich_hub_url_from_remote appends owner/slug from a matching remote."""
340
341 def _write_remote(self, repo: pathlib.Path, name: str, url: str) -> None:
342 from muse.cli.config import set_remote
343 set_remote(name, url, repo)
344
345 def test_enriches_bare_hub_url_with_owner_slug(self, repo: pathlib.Path) -> None:
346 from muse.cli.commands.hub import _enrich_hub_url_from_remote
347 self._write_remote(repo, "local", "http://localhost:10003/gabriel/muse")
348 result = _enrich_hub_url_from_remote("http://localhost:10003")
349 assert result == "http://localhost:10003/gabriel/muse"
350
351 def test_leaves_already_enriched_url_unchanged(self, repo: pathlib.Path) -> None:
352 from muse.cli.commands.hub import _enrich_hub_url_from_remote
353 self._write_remote(repo, "local", "http://localhost:10003/gabriel/muse")
354 result = _enrich_hub_url_from_remote("http://localhost:10003/gabriel/muse")
355 assert result == "http://localhost:10003/gabriel/muse"
356
357 def test_returns_bare_url_when_no_matching_remote(self, repo: pathlib.Path) -> None:
358 from muse.cli.commands.hub import _enrich_hub_url_from_remote
359 # remote is on a different host — no match
360 self._write_remote(repo, "staging", "http://otherhost:10003/gabriel/muse")
361 result = _enrich_hub_url_from_remote("http://localhost:10003")
362 assert result == "http://localhost:10003"
363
364 def test_returns_bare_url_when_no_remotes_configured(self, repo: pathlib.Path) -> None:
365 from muse.cli.commands.hub import _enrich_hub_url_from_remote
366 result = _enrich_hub_url_from_remote("http://localhost:10003")
367 assert result == "http://localhost:10003"
368
369 def test_returns_bare_url_outside_repo(
370 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
371 ) -> None:
372 monkeypatch.chdir(tmp_path)
373 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
374 from muse.cli.commands.hub import _enrich_hub_url_from_remote
375 result = _enrich_hub_url_from_remote("http://localhost:10003")
376 assert result == "http://localhost:10003"
377
378
379 # ---------------------------------------------------------------------------
380 # muse hub issue read (renamed from "get")
381 # ---------------------------------------------------------------------------
382
383
384 class TestHubIssueReadCommand:
385 """'muse hub issue read' is the canonical verb — 'get' is gone."""
386
387 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
388 set_hub_url(hub_url, repo)
389 identity = IdentityEntry(
390 type="human",
391 handle="gabriel",
392 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
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, "http://localhost:10003/gabriel/muse")
401 mock_resp = {"number": 1, "title": "Test", "state": "open", "body": ""}
402 with unittest.mock.patch(
403 "muse.cli.commands.hub._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, "http://localhost:10003/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 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
431 algorithm="ed25519",
432 fingerprint="deadbeef",
433 )
434 save_identity(hub_url, identity)
435
436 def test_proposal_read_subcommand_exists(self, repo: pathlib.Path) -> None:
437 """'read' must be a valid subcommand — exit code must not be 2."""
438 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
439 mock_proposal = {
440 "proposalId": "abc123",
441 "number": 1,
442 "title": "Test",
443 "state": "open",
444 "headBranch": "feat/x",
445 "baseBranch": "dev",
446 "author": "gabriel",
447 "createdAt": "2026-04-09T00:00:00Z",
448 "body": "",
449 }
450 with unittest.mock.patch(
451 "muse.cli.commands.hub._hub_api", return_value={"proposals": [mock_proposal]}
452 ):
453 result = runner.invoke(cli, ["hub", "proposal", "read", "abc123"])
454 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
455
456 def test_proposal_view_subcommand_removed(self, repo: pathlib.Path) -> None:
457 """'view' must no longer be accepted."""
458 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
459 result = runner.invoke(cli, ["hub", "proposal", "view", "abc123"])
460 assert result.exit_code != 0
461
462
463 # ---------------------------------------------------------------------------
464 # muse hub issue update (renamed from "edit")
465 # ---------------------------------------------------------------------------
466
467
468 class TestHubIssueUpdateCommand:
469 """'muse hub issue update' is the canonical verb — 'edit' is gone."""
470
471 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
472 set_hub_url(hub_url, repo)
473 identity = IdentityEntry(
474 type="human",
475 handle="gabriel",
476 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
477 algorithm="ed25519",
478 fingerprint="deadbeef",
479 )
480 save_identity(hub_url, identity)
481
482 def test_issue_update_subcommand_exists(self, repo: pathlib.Path) -> None:
483 """'update' must be a valid subcommand — exit code must not be 2 (unknown subcommand)."""
484 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
485 mock_resp = {"number": 1, "title": "Updated", "state": "open", "body": "new body"}
486 with unittest.mock.patch(
487 "muse.cli.commands.hub._hub_api", return_value=mock_resp
488 ):
489 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--body", "new body"])
490 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
491
492 def test_issue_edit_subcommand_removed(self, repo: pathlib.Path) -> None:
493 """'edit' must no longer be accepted."""
494 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
495 result = runner.invoke(cli, ["hub", "issue", "edit", "1", "--body", "x"])
496 assert result.exit_code != 0
497
498 def _hub_patches(self, calls: list[tuple]):
499 """Return a context manager that mocks all three hub network helpers."""
500 import unittest.mock as mock
501 mock_resp = {"number": 1, "title": "x", "state": "open"}
502
503 def _fake_api(hub_url, identity, method, path, **kw):
504 calls.append((method, path))
505 return mock_resp
506
507 return mock.patch.multiple(
508 "muse.cli.commands.hub",
509 _hub_api=mock.MagicMock(side_effect=_fake_api),
510 _get_hub_and_identity=mock.MagicMock(
511 return_value=("http://localhost:10003", mock.MagicMock())
512 ),
513 _resolve_repo_id=mock.MagicMock(return_value="test-repo-id"),
514 )
515
516 def test_issue_update_status_closed_calls_close_endpoint(self, repo: pathlib.Path) -> None:
517 """--status closed must call the /close endpoint, not PATCH."""
518 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
519 calls: list[tuple] = []
520 with self._hub_patches(calls):
521 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "closed"])
522 assert result.exit_code == 0, result.output
523 assert any("/close" in path for _, path in calls), (
524 f"--status closed must call the /close endpoint; got calls: {calls}"
525 )
526
527 def test_issue_update_status_open_calls_reopen_endpoint(self, repo: pathlib.Path) -> None:
528 """--status open must call the /reopen endpoint, not PATCH."""
529 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
530 calls: list[tuple] = []
531 with self._hub_patches(calls):
532 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "open"])
533 assert result.exit_code == 0, result.output
534 assert any("/reopen" in path for _, path in calls), (
535 f"--status open must call the /reopen endpoint; got calls: {calls}"
536 )
537
538 def test_issue_update_status_invalid_value_rejected(self, repo: pathlib.Path) -> None:
539 """--status must only accept 'open' or 'closed' — argparse rejects anything else."""
540 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
541 result = runner.invoke(cli, ["hub", "issue", "update", "1", "--status", "pending"])
542 assert result.exit_code != 0
543
544 def test_issue_update_status_and_body_together(self, repo: pathlib.Path) -> None:
545 """--status closed combined with --body must close AND update the body."""
546 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
547 calls: list[tuple] = []
548 with self._hub_patches(calls):
549 result = runner.invoke(
550 cli, ["hub", "issue", "update", "1", "--status", "closed", "--body", "done"]
551 )
552 assert result.exit_code == 0, result.output
553 assert any("/close" in p for _, p in calls), "close endpoint not called"
554 assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update"
555
556 def test_issue_update_assign_calls_assign_endpoint(self, repo: pathlib.Path) -> None:
557 """--assign <user> must POST to the /assign endpoint."""
558 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
559 calls: list[tuple] = []
560 with self._hub_patches(calls):
561 result = runner.invoke(
562 cli, ["hub", "issue", "update", "1", "--assign", "gabriel"]
563 )
564 assert result.exit_code == 0, result.output
565 assert any("/assign" in path for _, path in calls), (
566 f"--assign must call the /assign endpoint; got: {calls}"
567 )
568
569 def test_issue_update_assign_and_body_together(self, repo: pathlib.Path) -> None:
570 """--assign combined with --body must PATCH the body AND POST to /assign."""
571 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
572 calls: list[tuple] = []
573 with self._hub_patches(calls):
574 result = runner.invoke(
575 cli,
576 ["hub", "issue", "update", "1", "--assign", "gabriel", "--body", "impl done"],
577 )
578 assert result.exit_code == 0, result.output
579 assert any("/assign" in p for _, p in calls), "assign endpoint not called"
580 assert any(m == "PATCH" for m, _ in calls), "PATCH not called for body update"
581
582 def test_issue_update_assign_and_status_together(self, repo: pathlib.Path) -> None:
583 """--assign + --status closed must call /assign AND /close."""
584 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
585 calls: list[tuple] = []
586 with self._hub_patches(calls):
587 result = runner.invoke(
588 cli,
589 ["hub", "issue", "update", "1", "--assign", "gabriel", "--status", "closed"],
590 )
591 assert result.exit_code == 0, result.output
592 assert any("/assign" in p for _, p in calls), "assign endpoint not called"
593 assert any("/close" in p for _, p in calls), "close endpoint not called"
594
595
596 # ---------------------------------------------------------------------------
597 # muse hub repo update (renamed from "settings")
598 # ---------------------------------------------------------------------------
599
600
601 class TestHubRepoUpdateCommand:
602 """'muse hub repo update' is the canonical verb — 'settings' is gone."""
603
604 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
605 set_hub_url(hub_url, repo)
606 identity = IdentityEntry(
607 type="human",
608 handle="gabriel",
609 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
610 algorithm="ed25519",
611 fingerprint="deadbeef",
612 )
613 save_identity(hub_url, identity)
614
615 def test_repo_update_subcommand_exists(self, repo: pathlib.Path) -> None:
616 """'update' must be a valid repo subcommand — exit code must not be 2."""
617 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
618 mock_resp = {"repoId": "abc", "name": "muse", "owner": "gabriel"}
619 with unittest.mock.patch(
620 "muse.cli.commands.hub._hub_api", return_value=mock_resp
621 ):
622 result = runner.invoke(cli, ["hub", "repo", "update", "--description", "x"])
623 assert result.exit_code != 2, f"'update' is not a recognised subcommand: {result.output}"
624
625 def test_repo_settings_subcommand_removed(self, repo: pathlib.Path) -> None:
626 """'settings' must no longer be accepted."""
627 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
628 result = runner.invoke(cli, ["hub", "repo", "settings"])
629 assert result.exit_code != 0
630
631
632 # ---------------------------------------------------------------------------
633 # muse hub repo transfer-ownership (renamed from "transfer")
634 # ---------------------------------------------------------------------------
635
636
637 class TestHubRepoTransferOwnershipCommand:
638 """'muse hub repo transfer-ownership' is the canonical verb — 'transfer' is gone."""
639
640 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
641 set_hub_url(hub_url, repo)
642 identity = IdentityEntry(
643 type="human",
644 handle="gabriel",
645 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
646 algorithm="ed25519",
647 fingerprint="deadbeef",
648 )
649 save_identity(hub_url, identity)
650
651 def test_repo_transfer_ownership_subcommand_exists(self, repo: pathlib.Path) -> None:
652 """'transfer-ownership' must be a valid repo subcommand — exit code must not be 2."""
653 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
654 mock_resp = {"repoId": "abc", "name": "muse", "owner": "bob"}
655 with unittest.mock.patch(
656 "muse.cli.commands.hub._hub_api", return_value=mock_resp
657 ):
658 result = runner.invoke(cli, ["hub", "repo", "transfer-ownership", "--new-owner", "bob"])
659 assert result.exit_code != 2, f"'transfer-ownership' is not a recognised subcommand: {result.output}"
660
661 def test_repo_transfer_subcommand_removed(self, repo: pathlib.Path) -> None:
662 """'transfer' must no longer be accepted."""
663 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
664 result = runner.invoke(cli, ["hub", "repo", "transfer", "--new-owner", "bob"])
665 assert result.exit_code != 0
666
667
668 # ---------------------------------------------------------------------------
669 # muse hub collaborator update-permission (renamed from "update")
670 # ---------------------------------------------------------------------------
671
672
673 class TestHubCollaboratorUpdatePermissionCommand:
674 """'muse hub collaborator update-permission' is the canonical verb — 'update' is gone."""
675
676 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
677 set_hub_url(hub_url, repo)
678 identity = IdentityEntry(
679 type="human",
680 handle="gabriel",
681 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
682 algorithm="ed25519",
683 fingerprint="deadbeef",
684 )
685 save_identity(hub_url, identity)
686
687 def test_collaborator_update_permission_subcommand_exists(self, repo: pathlib.Path) -> None:
688 """'update-permission' must be a valid collaborator subcommand — exit code must not be 2."""
689 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
690 mock_resp = {"handle": "carol", "permission": "admin"}
691 with unittest.mock.patch(
692 "muse.cli.commands.hub._hub_api", return_value=mock_resp
693 ):
694 result = runner.invoke(
695 cli, ["hub", "collaborator", "update-permission", "carol", "--permission", "admin"]
696 )
697 assert result.exit_code != 2, f"'update-permission' is not a recognised subcommand: {result.output}"
698
699 def test_collaborator_update_subcommand_removed(self, repo: pathlib.Path) -> None:
700 """'update' must no longer be accepted as a collaborator subcommand."""
701 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
702 result = runner.invoke(
703 cli, ["hub", "collaborator", "update", "carol", "--permission", "admin"]
704 )
705 assert result.exit_code != 0
706
707
708 # ---------------------------------------------------------------------------
709 # muse hub repo list
710 # ---------------------------------------------------------------------------
711
712
713 class TestHubRepoListCommand:
714 """'muse hub repo list' lists repos for the authenticated user."""
715
716 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
717 set_hub_url(hub_url, repo)
718 identity = IdentityEntry(
719 type="human",
720 handle="gabriel",
721 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
722 algorithm="ed25519",
723 fingerprint="deadbeef",
724 )
725 save_identity(hub_url, identity)
726
727 def test_repo_list_subcommand_exists(self, repo: pathlib.Path) -> None:
728 """'list' must be a valid repo subcommand — exit code must not be 2."""
729 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
730 mock_resp = {
731 "total": 1,
732 "nextCursor": None,
733 "repos": [
734 {
735 "repoId": "abc",
736 "name": "my-repo",
737 "owner": "gabriel",
738 "slug": "my-repo",
739 "visibility": "public",
740 "description": "Test repo",
741 "tags": [],
742 "defaultBranch": "main",
743 "createdAt": "2026-01-01T00:00:00Z",
744 "pushedAt": "",
745 }
746 ],
747 }
748 with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp):
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, "http://localhost:10003/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("muse.cli.commands.hub._hub_api", return_value=mock_resp):
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, "http://localhost:10003/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("muse.cli.commands.hub._hub_api", return_value=mock_resp):
819 result = runner.invoke(cli, ["hub", "repo", "list", "--json"])
820
821 assert result.exit_code == 0
822 repos = json.loads(result.output)["repos"]
823 assert len(repos) == 1
824 for field in ("repo_id", "name", "owner", "slug", "visibility",
825 "description", "tags", "default_branch", "created_at", "pushed_at"):
826 assert field in repos[0], f"missing field: {field}"
827
828 def test_repo_list_passes_limit_to_api(self, repo: pathlib.Path) -> None:
829 """--limit is forwarded as a query parameter."""
830 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
831 captured: list[str] = []
832
833 def fake_api(hub_url: str, identity: object, method: str, path: str, **kwargs: object) -> object:
834 captured.append(path)
835 return {"total": 0, "nextCursor": None, "repos": []}
836
837 with unittest.mock.patch("muse.cli.commands.hub._hub_api", side_effect=fake_api):
838 runner.invoke(cli, ["hub", "repo", "list", "--limit", "42", "--json"])
839
840 assert captured, "no API call made"
841 assert "limit=42" in captured[0]
842
843 def test_repo_list_empty_prints_no_repos(self, repo: pathlib.Path) -> None:
844 """Empty repo list exits 0 and does not crash."""
845 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
846 mock_resp = {"total": 0, "nextCursor": None, "repos": []}
847 with unittest.mock.patch("muse.cli.commands.hub._hub_api", return_value=mock_resp):
848 result = runner.invoke(cli, ["hub", "repo", "list"])
849 assert result.exit_code == 0
850
851
852 # ---------------------------------------------------------------------------
853 # muse hub repo read
854 # ---------------------------------------------------------------------------
855
856
857 class TestHubRepoReadCommand:
858 """'muse hub repo read' fetches metadata for a single repo."""
859
860 def _setup_auth(self, repo: pathlib.Path, hub_url: str) -> None:
861 set_hub_url(hub_url, repo)
862 identity = IdentityEntry(
863 type="human",
864 handle="gabriel",
865 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
866 algorithm="ed25519",
867 fingerprint="deadbeef",
868 )
869 save_identity(hub_url, identity)
870
871 _MOCK_REPO = {
872 "repoId": "abc123",
873 "name": "jazz-standards",
874 "owner": "gabriel",
875 "slug": "jazz-standards",
876 "visibility": "public",
877 "description": "A collection of jazz standards",
878 "tags": ["music", "jazz"],
879 "defaultBranch": "main",
880 "cloneUrl": "http://localhost:10003/gabriel/jazz-standards",
881 "createdAt": "2026-01-01T00:00:00Z",
882 "updatedAt": "2026-02-01T00:00:00Z",
883 "pushedAt": "2026-03-01T00:00:00Z",
884 }
885
886 def test_repo_read_subcommand_exists(self, repo: pathlib.Path) -> None:
887 """'read' must be a valid repo subcommand — exit code must not be 2."""
888 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
889 with unittest.mock.patch(
890 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
891 ):
892 result = runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards"])
893 assert result.exit_code != 2, f"'read' is not a recognised subcommand: {result.output}"
894
895 def test_repo_read_json_output_structure(self, repo: pathlib.Path) -> None:
896 """--json emits all documented repo fields to stdout."""
897 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
898 with unittest.mock.patch(
899 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
900 ):
901 result = runner.invoke(
902 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
903 )
904
905 assert result.exit_code == 0, result.output
906 out = json.loads(result.output)
907 assert out["repo_id"] == "abc123"
908 assert out["slug"] == "jazz-standards"
909 assert out["owner"] == "gabriel"
910 assert out["visibility"] == "public"
911
912 def test_repo_read_json_fields_present(self, repo: pathlib.Path) -> None:
913 """All documented fields are present in --json output."""
914 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
915 with unittest.mock.patch(
916 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
917 ):
918 result = runner.invoke(
919 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
920 )
921
922 assert result.exit_code == 0
923 out = json.loads(result.output)
924 for field in ("repo_id", "name", "owner", "slug", "visibility",
925 "description", "tags", "default_branch",
926 "clone_url", "created_at", "updated_at", "pushed_at"):
927 assert field in out, f"missing field: {field}"
928
929 def test_repo_read_uses_owner_slug_url(self, repo: pathlib.Path) -> None:
930 """OWNER/SLUG argument resolves via /api/{owner}/{slug} not /api/repos/{id}."""
931 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
932 captured: list[str] = []
933
934 def fake_api(hub_url: str, identity: object, method: str, path: str, **kwargs: object) -> object:
935 captured.append(path)
936 return self._MOCK_REPO
937
938 with unittest.mock.patch("muse.cli.commands.hub._hub_api", side_effect=fake_api):
939 runner.invoke(cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"])
940
941 assert captured, "no API call made"
942 assert "/api/gabriel/jazz-standards" in captured[0]
943
944 def test_repo_read_tags_preserved(self, repo: pathlib.Path) -> None:
945 """Tags list is preserved intact in JSON output."""
946 self._setup_auth(repo, "http://localhost:10003/gabriel/muse")
947 with unittest.mock.patch(
948 "muse.cli.commands.hub._hub_api", return_value=self._MOCK_REPO
949 ):
950 result = runner.invoke(
951 cli, ["hub", "repo", "read", "gabriel/jazz-standards", "--json"]
952 )
953
954 assert result.exit_code == 0
955 out = json.loads(result.output)
956 assert out["tags"] == ["music", "jazz"]
957
958
959 class TestHubRepoDeleteCommand:
960 """Tests for run_repo_delete with the new TARGET argument."""
961
962 def test_delete_by_uuid_calls_correct_endpoint(self) -> None:
963 """run_repo_delete with a UUID target calls DELETE /api/repos/{uuid}."""
964 import argparse
965 import unittest.mock as mock
966 from muse.cli.commands.hub import run_repo_delete
967
968 repo_id = "a3f2c9d1-0000-0000-0000-000000000001"
969
970 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
971 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
972 return_value=("http://localhost:10003", mock.MagicMock())):
973 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
974 run_repo_delete(args)
975
976 calls = [str(c) for c in m_api.call_args_list]
977 assert any(f"/api/repos/{repo_id}" in c for c in calls), (
978 f"Expected DELETE /api/repos/{repo_id}, got: {calls}"
979 )
980
981 def test_delete_by_owner_slug_resolves_then_deletes(self) -> None:
982 """run_repo_delete with OWNER/SLUG fetches repo_id then DELETEs it."""
983 import argparse
984 import unittest.mock as mock
985 from muse.cli.commands.hub import run_repo_delete
986
987 resolved_id = "b4e5d6f7-0000-0000-0000-000000000002"
988 get_resp = {"repoId": resolved_id, "name": "my-repo", "owner": "gabriel"}
989
990 with mock.patch("muse.cli.commands.hub._hub_api",
991 side_effect=[get_resp, {}]) as m_api, \
992 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
993 return_value=("http://localhost:10003", mock.MagicMock())):
994 args = argparse.Namespace(target="gabriel/my-repo", yes=True,
995 hub=None, json_output=True)
996 run_repo_delete(args)
997
998 calls = m_api.call_args_list
999 assert len(calls) == 2
1000 # First: GET to resolve owner/slug
1001 assert calls[0].args[2] == "GET"
1002 assert "/api/gabriel/my-repo" in calls[0].args[3]
1003 # Second: DELETE with resolved UUID
1004 assert calls[1].args[2] == "DELETE"
1005 assert f"/api/repos/{resolved_id}" in calls[1].args[3]
1006
1007 def test_delete_without_yes_exits_nonzero_and_skips_api(self) -> None:
1008 """Without --yes, exits non-zero and never calls the API."""
1009 import argparse
1010 import pytest
1011 import unittest.mock as mock
1012 from muse.cli.commands.hub import run_repo_delete
1013
1014 with mock.patch("muse.cli.commands.hub._hub_api") as m_api, \
1015 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1016 return_value=("http://localhost:10003", mock.MagicMock())):
1017 args = argparse.Namespace(target="gabriel/my-repo", yes=False,
1018 hub=None, json_output=False)
1019 with pytest.raises(SystemExit) as exc_info:
1020 run_repo_delete(args)
1021
1022 assert exc_info.value.code != 0
1023 m_api.assert_not_called()
1024
1025 def test_delete_no_target_falls_back_to_config_resolution(self) -> None:
1026 """When target is None, repo_id is resolved from the current directory config."""
1027 import argparse
1028 import unittest.mock as mock
1029 from muse.cli.commands.hub import run_repo_delete
1030
1031 config_repo_id = "c5f6e7a8-0000-0000-0000-000000000003"
1032
1033 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}) as m_api, \
1034 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1035 return_value=("http://localhost:10003", mock.MagicMock())), \
1036 mock.patch("muse.cli.commands.hub._resolve_repo_id",
1037 return_value=config_repo_id):
1038 args = argparse.Namespace(target=None, yes=True, hub=None, json_output=True)
1039 run_repo_delete(args)
1040
1041 calls = [str(c) for c in m_api.call_args_list]
1042 assert any(f"/api/repos/{config_repo_id}" in c for c in calls), (
1043 f"Expected DELETE using config repo_id, got: {calls}"
1044 )
1045
1046 def test_delete_json_output_emits_structured_result(self) -> None:
1047 """--json flag emits {deleted: true, repo_id: ...} to stdout."""
1048 import argparse
1049 import io
1050 import json as json_mod
1051 import sys
1052 import unittest.mock as mock
1053 from muse.cli.commands.hub import run_repo_delete
1054
1055 repo_id = "d6g7h8i9-0000-0000-0000-000000000004"
1056
1057 with mock.patch("muse.cli.commands.hub._hub_api", return_value={}), \
1058 mock.patch("muse.cli.commands.hub._get_hub_and_identity",
1059 return_value=("http://localhost:10003", mock.MagicMock())):
1060 args = argparse.Namespace(target=repo_id, yes=True, hub=None, json_output=True)
1061 captured = io.StringIO()
1062 with mock.patch("sys.stdout", captured):
1063 run_repo_delete(args)
1064
1065 output = json_mod.loads(captured.getvalue())
1066 assert output["deleted"] is True
1067 assert output["repo_id"] == repo_id
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago