gabriel / musehub public
test_musehub_repos.py python
2,015 lines 67.0 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 9 days ago
1 """Tests for MuseHub repo, branch, and commit endpoints.
2
3 Covers every acceptance criterion:
4 - POST /musehub/repos returns 201 with correct fields
5 - POST requires auth — unauthenticated requests return 401
6 - GET /repos/{repo_id} returns 200; 404 for unknown repo
7 - GET /repos/{repo_id}/branches returns empty list on new repo
8 - GET /repos/{repo_id}/commits returns newest first, respects ?limit
9
10 Covers (compare view API endpoint):
11 - test_compare_radar_data — compare endpoint returns 5 dimension scores
12 - test_compare_commit_list — commits unique to head are listed
13 - test_compare_unknown_ref_404 — unknown ref returns 422
14
15 All tests use the shared ``client`` and ``auth_headers`` fixtures from conftest.py.
16 """
17 from __future__ import annotations
18
19 from datetime import datetime, timezone
20
21 import pytest
22 from httpx import AsyncClient
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.core.genesis import compute_collaborator_id, compute_identity_id, compute_repo_id
26 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
27 from musehub.services import musehub_repository
28 from musehub.types.json_types import StrDict
29
30
31 def _make_repo(
32 slug: str,
33 owner: str = "testuser",
34 owner_user_id: str | None = None,
35 visibility: str = "private",
36 **kwargs: str | int | bool | None,
37 ) -> MusehubRepo:
38 if owner_user_id is None:
39 owner_user_id = TEST_OWNER_USER_ID
40 created_at = datetime.now(tz=timezone.utc)
41 return MusehubRepo(
42 repo_id=compute_repo_id(owner_user_id, slug, "code", created_at.isoformat()),
43 name=slug,
44 owner=owner,
45 slug=slug,
46 visibility=visibility,
47 owner_user_id=owner_user_id,
48 created_at=created_at,
49 updated_at=created_at,
50 **kwargs,
51 )
52
53
54 # ---------------------------------------------------------------------------
55 # POST /musehub/repos
56 # ---------------------------------------------------------------------------
57
58
59 async def test_create_repo_returns_201(
60 client: AsyncClient,
61 auth_headers: StrDict,
62 ) -> None:
63 """POST /musehub/repos creates a repo and returns all required fields."""
64 response = await client.post(
65 "/api/repos",
66 json={"name": "my-beats", "owner": "testuser", "visibility": "private"},
67 headers=auth_headers,
68 )
69 assert response.status_code == 201
70 body = response.json()
71 assert body["name"] == "my-beats"
72 assert body["visibility"] == "private"
73 assert "repoId" in body
74 assert "cloneUrl" in body
75 assert "ownerUserId" in body
76 assert "createdAt" in body
77
78
79 async def test_create_repo_requires_auth(client: AsyncClient) -> None:
80 """POST /musehub/repos returns 401 without a MSign Authorization header."""
81 response = await client.post(
82 "/api/repos",
83 json={"name": "my-beats", "owner": "testuser"},
84 )
85 assert response.status_code == 401
86
87
88 async def test_create_repo_default_visibility_is_public(
89 client: AsyncClient,
90 auth_headers: StrDict,
91 ) -> None:
92 """Omitting visibility defaults to 'public'."""
93 response = await client.post(
94 "/api/repos",
95 json={"name": "silent-sessions", "owner": "testuser"},
96 headers=auth_headers,
97 )
98 assert response.status_code == 201
99 assert response.json()["visibility"] == "public"
100
101
102 # ---------------------------------------------------------------------------
103 # GET /repos/{repo_id}
104 # ---------------------------------------------------------------------------
105
106
107 async def test_get_repo_returns_200(
108 client: AsyncClient,
109 auth_headers: StrDict,
110 ) -> None:
111 """GET /repos/{repo_id} returns the repo after creation."""
112 create = await client.post(
113 "/api/repos",
114 json={"name": "jazz-sessions", "owner": "testuser"},
115 headers=auth_headers,
116 )
117 assert create.status_code == 201
118 repo_id = create.json()["repoId"]
119
120 response = await client.get(f"/api/repos/{repo_id}", headers=auth_headers)
121 assert response.status_code == 200
122 assert response.json()["repoId"] == repo_id
123 assert response.json()["name"] == "jazz-sessions"
124
125
126 async def test_get_repo_not_found_returns_404(
127 client: AsyncClient,
128 auth_headers: StrDict,
129 ) -> None:
130 """GET /repos/{repo_id} returns 404 for unknown repo."""
131 response = await client.get(
132 "/api/repos/does-not-exist",
133 headers=auth_headers,
134 )
135 assert response.status_code == 404
136
137
138 async def test_get_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
139 """GET /repos/{repo_id} returns 404 for a non-existent repo without auth.
140
141 Uses optional_token — auth is visibility-based; missing repo → 404 before auth check.
142 """
143 response = await client.get("/api/repos/non-existent-repo-id")
144 assert response.status_code == 404
145
146
147 # ---------------------------------------------------------------------------
148 # GET /repos/{repo_id}/branches
149 # ---------------------------------------------------------------------------
150
151
152 async def test_list_branches_empty_on_new_repo(
153 client: AsyncClient,
154 auth_headers: StrDict,
155 ) -> None:
156 """A newly created repo has an empty branches list when not initialized."""
157 create = await client.post(
158 "/api/repos",
159 json={"name": "drum-patterns", "owner": "testuser", "initialize": False},
160 headers=auth_headers,
161 )
162 repo_id = create.json()["repoId"]
163
164 response = await client.get(
165 f"/api/repos/{repo_id}/branches",
166 headers=auth_headers,
167 )
168 assert response.status_code == 200
169 assert response.json()["branches"] == []
170
171
172 async def test_list_branches_not_found_returns_404(
173 client: AsyncClient,
174 auth_headers: StrDict,
175 ) -> None:
176 """GET /branches returns 404 when the repo doesn't exist."""
177 response = await client.get(
178 "/api/repos/ghost-repo/branches",
179 headers=auth_headers,
180 )
181 assert response.status_code == 404
182
183
184 # ---------------------------------------------------------------------------
185 # GET /repos/{repo_id}/commits
186 # ---------------------------------------------------------------------------
187
188
189 async def test_list_commits_empty_on_new_repo(
190 client: AsyncClient,
191 auth_headers: StrDict,
192 ) -> None:
193 """A new repo has no commits when initialize=false."""
194 create = await client.post(
195 "/api/repos",
196 json={"name": "empty-repo", "owner": "testuser", "initialize": False},
197 headers=auth_headers,
198 )
199 repo_id = create.json()["repoId"]
200
201 response = await client.get(
202 f"/api/repos/{repo_id}/commits",
203 headers=auth_headers,
204 )
205 assert response.status_code == 200
206 body = response.json()
207 assert body["commits"] == []
208 assert body["total"] == 0
209
210
211 async def test_list_commits_returns_newest_first(
212 client: AsyncClient,
213 auth_headers: StrDict,
214 db_session: AsyncSession,
215 ) -> None:
216 """Commits are returned newest-first after being pushed."""
217 from datetime import datetime, timezone, timedelta
218
219 # Create repo via API (no init commit so we control the full history)
220 create = await client.post(
221 "/api/repos",
222 json={"name": "ordered-commits", "owner": "testuser", "initialize": False},
223 headers=auth_headers,
224 )
225 repo_id = create.json()["repoId"]
226
227 # Insert two commits directly with known timestamps
228 now = datetime.now(tz=timezone.utc)
229 older = MusehubCommit(
230 commit_id="aaa111",
231 branch="main",
232 parent_ids=[],
233 message="first",
234 author="gabriel",
235 timestamp=now - timedelta(hours=1),
236 )
237 newer = MusehubCommit(
238 commit_id="bbb222",
239 branch="main",
240 parent_ids=["aaa111"],
241 message="second",
242 author="gabriel",
243 timestamp=now,
244 )
245 db_session.add_all([older, newer])
246 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="aaa111"))
247 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="bbb222"))
248 await db_session.commit()
249
250 response = await client.get(
251 f"/api/repos/{repo_id}/commits",
252 headers=auth_headers,
253 )
254 assert response.status_code == 200
255 commits = response.json()["commits"]
256 assert len(commits) == 2
257 assert commits[0]["commitId"] == "bbb222"
258 assert commits[1]["commitId"] == "aaa111"
259
260
261 async def test_list_commits_limit_param(
262 client: AsyncClient,
263 auth_headers: StrDict,
264 db_session: AsyncSession,
265 ) -> None:
266 """?limit=1 returns exactly 1 commit."""
267 from datetime import datetime, timezone, timedelta
268
269 create = await client.post(
270 "/api/repos",
271 json={"name": "limited-repo", "owner": "testuser", "initialize": False},
272 headers=auth_headers,
273 )
274 repo_id = create.json()["repoId"]
275
276 now = datetime.now(tz=timezone.utc)
277 for i in range(3):
278 db_session.add(
279 MusehubCommit(
280 commit_id=f"commit-{i}",
281 branch="main",
282 parent_ids=[],
283 message=f"commit {i}",
284 author="gabriel",
285 timestamp=now + timedelta(seconds=i),
286 )
287 )
288 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=f"commit-{i}"))
289 await db_session.commit()
290
291 response = await client.get(
292 f"/api/repos/{repo_id}/commits?limit=1",
293 headers=auth_headers,
294 )
295 assert response.status_code == 200
296 body = response.json()
297 assert len(body["commits"]) == 1
298 assert body["total"] == 3
299
300
301 # ---------------------------------------------------------------------------
302 # Service layer — direct DB tests (no HTTP)
303 # ---------------------------------------------------------------------------
304
305
306 async def test_create_repo_service_persists_to_db(db_session: AsyncSession) -> None:
307 """musehub_repository.create_repo() persists the row."""
308 repo = await musehub_repository.create_repo(
309 db_session,
310 name="service-test-repo",
311 owner="testuser",
312 visibility="public",
313 owner_user_id=compute_identity_id(b"testuser"),
314 )
315 await db_session.commit()
316
317 fetched = await musehub_repository.get_repo(db_session, repo.repo_id)
318 assert fetched is not None
319 assert fetched.name == "service-test-repo"
320 assert fetched.visibility == "public"
321
322
323 async def test_get_repo_returns_none_when_missing(db_session: AsyncSession) -> None:
324 """get_repo() returns None for an unknown repo_id."""
325 result = await musehub_repository.get_repo(db_session, "nonexistent-id")
326 assert result is None
327
328
329 async def test_list_branches_returns_empty_for_new_repo(db_session: AsyncSession) -> None:
330 """list_branches() returns [] for a repo with no branches."""
331 repo = await musehub_repository.create_repo(
332 db_session,
333 name="branchless",
334 owner="testuser",
335 visibility="private",
336 owner_user_id=compute_identity_id(b"testuser"),
337 )
338 await db_session.commit()
339 branches = await musehub_repository.list_branches(db_session, repo.repo_id)
340 assert branches == []
341
342
343 # ---------------------------------------------------------------------------
344 # GET /repos/{repo_id}/divergence
345 # ---------------------------------------------------------------------------
346
347
348 async def test_divergence_endpoint_returns_five_dimensions(
349 client: AsyncClient,
350 auth_headers: StrDict,
351 db_session: AsyncSession,
352 ) -> None:
353 """GET /divergence returns five dimension scores with level labels."""
354 from datetime import datetime, timezone, timedelta
355
356 create = await client.post(
357 "/api/repos",
358 json={"name": "divergence-test-repo", "owner": "testuser"},
359 headers=auth_headers,
360 )
361 assert create.status_code == 201
362 repo_id = create.json()["repoId"]
363
364 now = datetime.now(tz=timezone.utc)
365 db_session.add(MusehubCommit(commit_id="aaa-melody", branch="main", parent_ids=[], message="add lead melody line", author="alice", timestamp=now - timedelta(hours=2)))
366 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="aaa-melody"))
367 db_session.add(MusehubCommit(commit_id="bbb-chord", branch="feature", parent_ids=[], message="update chord progression", author="bob", timestamp=now - timedelta(hours=1)))
368 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="bbb-chord"))
369 await db_session.commit()
370
371 response = await client.get(
372 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=feature",
373 headers=auth_headers,
374 )
375 assert response.status_code == 200
376 body = response.json()
377 assert "dimensions" in body
378 assert len(body["dimensions"]) == 5
379
380 dim_names = {d["dimension"] for d in body["dimensions"]}
381 assert dim_names == {"melodic", "harmonic", "rhythmic", "structural", "dynamic"}
382
383 for dim in body["dimensions"]:
384 assert "level" in dim
385 assert dim["level"] in {"NONE", "LOW", "MED", "HIGH"}
386 assert "score" in dim
387 assert 0.0 <= dim["score"] <= 1.0
388
389
390 async def test_divergence_overall_score_is_mean_of_dimensions(
391 client: AsyncClient,
392 auth_headers: StrDict,
393 db_session: AsyncSession,
394 ) -> None:
395 """Overall divergence score equals the mean of all five dimension scores."""
396 from datetime import datetime, timezone, timedelta
397
398 create = await client.post(
399 "/api/repos",
400 json={"name": "divergence-mean-repo", "owner": "testuser"},
401 headers=auth_headers,
402 )
403 repo_id = create.json()["repoId"]
404
405 now = datetime.now(tz=timezone.utc)
406 db_session.add(MusehubCommit(commit_id="c1-beat", branch="alpha", parent_ids=[], message="rework drum beat groove", author="producer-a", timestamp=now - timedelta(hours=3)))
407 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="c1-beat"))
408 db_session.add(MusehubCommit(commit_id="c2-mix", branch="beta", parent_ids=[], message="fix master volume level", author="producer-b", timestamp=now - timedelta(hours=2)))
409 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="c2-mix"))
410 await db_session.commit()
411
412 response = await client.get(
413 f"/api/repos/{repo_id}/divergence?branch_a=alpha&branch_b=beta",
414 headers=auth_headers,
415 )
416 assert response.status_code == 200
417 body = response.json()
418
419 dims = body["dimensions"]
420 computed_mean = round(sum(d["score"] for d in dims) / len(dims), 4)
421 assert abs(body["overallScore"] - computed_mean) < 1e-6
422
423
424 async def test_divergence_json_response_structure(
425 client: AsyncClient,
426 auth_headers: StrDict,
427 db_session: AsyncSession,
428 ) -> None:
429 """JSON response has all required top-level fields and camelCase keys."""
430 from datetime import datetime, timezone, timedelta
431
432 create = await client.post(
433 "/api/repos",
434 json={"name": "divergence-struct-repo", "owner": "testuser"},
435 headers=auth_headers,
436 )
437 repo_id = create.json()["repoId"]
438
439 now = datetime.now(tz=timezone.utc)
440 for i, (branch, msg) in enumerate(
441 [("main", "add melody riff"), ("dev", "update chorus section")]
442 ):
443 db_session.add(MusehubCommit(commit_id=f"struct-{i}", branch=branch, parent_ids=[], message=msg, author="test", timestamp=now + timedelta(seconds=i)))
444 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=f"struct-{i}"))
445 await db_session.commit()
446
447 response = await client.get(
448 f"/api/repos/{repo_id}/divergence?branch_a=main&branch_b=dev",
449 headers=auth_headers,
450 )
451 assert response.status_code == 200
452 body = response.json()
453
454 assert body["repoId"] == repo_id
455 assert body["branchA"] == "main"
456 assert body["branchB"] == "dev"
457 assert "commonAncestor" in body
458 assert "overallScore" in body
459 assert isinstance(body["overallScore"], float)
460 assert isinstance(body["dimensions"], list)
461 assert len(body["dimensions"]) == 5
462
463 for dim in body["dimensions"]:
464 assert "dimension" in dim
465 assert "level" in dim
466 assert "score" in dim
467 assert "description" in dim
468 assert "branchACommits" in dim
469 assert "branchBCommits" in dim
470
471
472 async def test_divergence_endpoint_returns_404_for_unknown_repo(
473 client: AsyncClient,
474 auth_headers: StrDict,
475 ) -> None:
476 """GET /divergence returns 404 for an unknown repo."""
477 response = await client.get(
478 "/api/repos/no-such-repo/divergence?branch_a=a&branch_b=b",
479 headers=auth_headers,
480 )
481 assert response.status_code == 404
482
483
484 async def test_divergence_endpoint_returns_422_for_empty_branch(
485 client: AsyncClient,
486 auth_headers: StrDict,
487 db_session: AsyncSession,
488 ) -> None:
489 """GET /divergence returns 422 when a branch has no commits."""
490 create = await client.post(
491 "/api/repos",
492 json={"name": "empty-branch-repo", "owner": "testuser"},
493 headers=auth_headers,
494 )
495 repo_id = create.json()["repoId"]
496
497 response = await client.get(
498 f"/api/repos/{repo_id}/divergence?branch_a=ghost&branch_b=also-ghost",
499 headers=auth_headers,
500 )
501 assert response.status_code == 422
502
503
504
505 # ---------------------------------------------------------------------------
506 # GET /repos/{repo_id}/dag
507 # ---------------------------------------------------------------------------
508
509
510 async def test_graph_dag_endpoint_returns_empty_for_new_repo(
511 client: AsyncClient,
512 auth_headers: StrDict,
513 ) -> None:
514 """GET /dag returns empty nodes/edges for a repo with no commits (initialize=false)."""
515 create = await client.post(
516 "/api/repos",
517 json={"name": "dag-empty", "owner": "testuser", "initialize": False},
518 headers=auth_headers,
519 )
520 repo_id = create.json()["repoId"]
521
522 response = await client.get(
523 f"/api/repos/{repo_id}/dag",
524 headers=auth_headers,
525 )
526 assert response.status_code == 200
527 body = response.json()
528 assert body["nodes"] == []
529 assert body["edges"] == []
530 assert body["headCommitId"] is None
531
532
533 async def test_graph_dag_has_edges(
534 client: AsyncClient,
535 auth_headers: StrDict,
536 db_session: AsyncSession,
537 ) -> None:
538 """DAG endpoint returns correct edges representing parent relationships."""
539 from datetime import datetime, timezone, timedelta
540
541 create = await client.post(
542 "/api/repos",
543 json={"name": "dag-edges", "owner": "testuser", "initialize": False},
544 headers=auth_headers,
545 )
546 repo_id = create.json()["repoId"]
547
548 now = datetime.now(tz=timezone.utc)
549 root = MusehubCommit(commit_id="root111", branch="main", parent_ids=[], message="root commit", author="gabriel", timestamp=now - timedelta(hours=2))
550 child = MusehubCommit(commit_id="child222", branch="main", parent_ids=["root111"], message="child commit", author="gabriel", timestamp=now - timedelta(hours=1))
551 db_session.add_all([root, child])
552 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="root111"))
553 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="child222"))
554 await db_session.commit()
555
556 response = await client.get(
557 f"/api/repos/{repo_id}/dag",
558 headers=auth_headers,
559 )
560 assert response.status_code == 200
561 body = response.json()
562 nodes = body["nodes"]
563 edges = body["edges"]
564
565 assert len(nodes) == 2
566 # Verify edge: child → root
567 assert any(e["source"] == "child222" and e["target"] == "root111" for e in edges)
568
569
570 async def test_graph_dag_endpoint_topological_order(
571 client: AsyncClient,
572 auth_headers: StrDict,
573 db_session: AsyncSession,
574 ) -> None:
575 """DAG endpoint returns nodes in topological order (oldest ancestor first)."""
576 from datetime import datetime, timedelta, timezone
577
578 create = await client.post(
579 "/api/repos",
580 json={"name": "dag-topo", "owner": "testuser"},
581 headers=auth_headers,
582 )
583 repo_id = create.json()["repoId"]
584
585 now = datetime.now(tz=timezone.utc)
586 commits = [
587 MusehubCommit(commit_id="topo-a", branch="main", parent_ids=[], message="root", author="gabriel", timestamp=now - timedelta(hours=3)),
588 MusehubCommit(commit_id="topo-b", branch="main", parent_ids=["topo-a"], message="second", author="gabriel", timestamp=now - timedelta(hours=2)),
589 MusehubCommit(commit_id="topo-c", branch="main", parent_ids=["topo-b"], message="third", author="gabriel", timestamp=now - timedelta(hours=1)),
590 ]
591 db_session.add_all(commits)
592 for cid in ("topo-a", "topo-b", "topo-c"):
593 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cid))
594 await db_session.commit()
595
596 response = await client.get(
597 f"/api/repos/{repo_id}/dag",
598 headers=auth_headers,
599 )
600 assert response.status_code == 200
601 node_ids = [n["commitId"] for n in response.json()["nodes"]]
602 # Root must appear before children in topological order
603 assert node_ids.index("topo-a") < node_ids.index("topo-b")
604 assert node_ids.index("topo-b") < node_ids.index("topo-c")
605
606
607 async def test_graph_dag_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
608 """GET /dag returns 404 for a non-existent repo without a token.
609
610 Uses optional_token — auth is visibility-based; missing repo → 404.
611 """
612 response = await client.get("/api/repos/non-existent-repo/dag")
613 assert response.status_code == 404
614
615
616 async def test_graph_dag_404_for_unknown_repo(
617 client: AsyncClient,
618 auth_headers: StrDict,
619 ) -> None:
620 """GET /dag returns 404 for a non-existent repo."""
621 response = await client.get(
622 "/api/repos/ghost-repo-dag/dag",
623 headers=auth_headers,
624 )
625 assert response.status_code == 404
626
627
628 async def test_graph_json_response_has_required_fields(
629 client: AsyncClient,
630 auth_headers: StrDict,
631 db_session: AsyncSession,
632 ) -> None:
633 """DAG JSON response includes nodes (with required fields) and edges arrays."""
634 from datetime import datetime, timezone
635
636 create = await client.post(
637 "/api/repos",
638 json={"name": "dag-fields", "owner": "testuser"},
639 headers=auth_headers,
640 )
641 repo_id = create.json()["repoId"]
642
643 db_session.add(MusehubCommit(commit_id="fields-aaa", branch="main", parent_ids=[], message="check fields", author="tester", timestamp=datetime.now(tz=timezone.utc)))
644 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="fields-aaa"))
645 await db_session.commit()
646
647 response = await client.get(
648 f"/api/repos/{repo_id}/dag",
649 headers=auth_headers,
650 )
651 assert response.status_code == 200
652 body = response.json()
653 assert "nodes" in body
654 assert "edges" in body
655 assert "headCommitId" in body
656
657 node = body["nodes"][0]
658 for field in ("commitId", "message", "author", "timestamp", "branch", "parentIds", "isHead"):
659 assert field in node, f"Missing field '{field}' in DAG node"
660
661 # ---------------------------------------------------------------------------
662 # GET /repos/{repo_id}/credits
663 # ---------------------------------------------------------------------------
664
665
666 async def _seed_credits_repo(db_session: AsyncSession) -> str:
667 """Create a repo with commits from two distinct authors and return repo_id."""
668 from datetime import timedelta
669
670 repo = _make_repo("liner-notes", visibility="public")
671 db_session.add(repo)
672 await db_session.flush()
673 repo_id = str(repo.repo_id)
674
675 now = datetime.now(tz=timezone.utc)
676 # Alice: 2 commits (most prolific), most recent 1 day ago
677 db_session.add(MusehubCommit(commit_id="alice-001", branch="main", parent_ids=[], message="compose the main melody", author="Alice", timestamp=now - timedelta(days=3)))
678 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="alice-001"))
679 db_session.add(MusehubCommit(commit_id="alice-002", branch="main", parent_ids=["alice-001"], message="mix the final arrangement", author="Alice", timestamp=now - timedelta(days=1)))
680 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="alice-002"))
681 # Bob: 1 commit, last active 5 days ago
682 db_session.add(MusehubCommit(commit_id="bob-001", branch="main", parent_ids=[], message="arrange the bridge section", author="Bob", timestamp=now - timedelta(days=5)))
683 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="bob-001"))
684 await db_session.commit()
685 return repo_id
686
687
688 async def test_credits_aggregation(
689 client: AsyncClient,
690 db_session: AsyncSession,
691 auth_headers: StrDict,
692 ) -> None:
693 """GET /api/repos/{repo_id}/credits aggregates contributors from commits."""
694 repo_id = await _seed_credits_repo(db_session)
695 response = await client.get(
696 f"/api/repos/{repo_id}/credits",
697 headers=auth_headers,
698 )
699 assert response.status_code == 200
700 body = response.json()
701 assert body["totalContributors"] == 2
702 authors = {c["author"] for c in body["contributors"]}
703 assert "Alice" in authors
704 assert "Bob" in authors
705
706
707 async def test_credits_sorted_by_count(
708 client: AsyncClient,
709 db_session: AsyncSession,
710 auth_headers: StrDict,
711 ) -> None:
712 """Default sort (count) puts the most prolific contributor first."""
713 repo_id = await _seed_credits_repo(db_session)
714 response = await client.get(
715 f"/api/repos/{repo_id}/credits?sort=count",
716 headers=auth_headers,
717 )
718 assert response.status_code == 200
719 contributors = response.json()["contributors"]
720 assert contributors[0]["author"] == "Alice"
721 assert contributors[0]["sessionCount"] == 2
722
723
724 async def test_credits_sorted_by_recency(
725 client: AsyncClient,
726 db_session: AsyncSession,
727 auth_headers: StrDict,
728 ) -> None:
729 """sort=recency puts the most recently active contributor first."""
730 repo_id = await _seed_credits_repo(db_session)
731 response = await client.get(
732 f"/api/repos/{repo_id}/credits?sort=recency",
733 headers=auth_headers,
734 )
735 assert response.status_code == 200
736 contributors = response.json()["contributors"]
737 # Alice has a commit 1 day ago; Bob's last was 5 days ago
738 assert contributors[0]["author"] == "Alice"
739
740
741 async def test_credits_sorted_by_alpha(
742 client: AsyncClient,
743 db_session: AsyncSession,
744 auth_headers: StrDict,
745 ) -> None:
746 """sort=alpha returns contributors in alphabetical order."""
747 repo_id = await _seed_credits_repo(db_session)
748 response = await client.get(
749 f"/api/repos/{repo_id}/credits?sort=alpha",
750 headers=auth_headers,
751 )
752 assert response.status_code == 200
753 contributors = response.json()["contributors"]
754 authors = [c["author"] for c in contributors]
755 assert authors == sorted(authors, key=str.lower)
756
757
758 async def test_credits_contribution_types_inferred(
759 client: AsyncClient,
760 db_session: AsyncSession,
761 auth_headers: StrDict,
762 ) -> None:
763 """Contribution types are inferred from commit messages."""
764 repo_id = await _seed_credits_repo(db_session)
765 response = await client.get(
766 f"/api/repos/{repo_id}/credits",
767 headers=auth_headers,
768 )
769 assert response.status_code == 200
770 contributors = response.json()["contributors"]
771 alice = next(c for c in contributors if c["author"] == "Alice")
772 # Alice's commits mention "compose" and "mix"
773 types = set(alice["contributionTypes"])
774 assert len(types) > 0
775
776
777 async def test_credits_404_for_unknown_repo(
778 client: AsyncClient,
779 auth_headers: StrDict,
780 ) -> None:
781 """GET /api/repos/{unknown}/credits returns 404."""
782 response = await client.get(
783 "/api/repos/does-not-exist/credits",
784 headers=auth_headers,
785 )
786 assert response.status_code == 404
787
788
789 async def test_credits_requires_auth(
790 client: AsyncClient,
791 db_session: AsyncSession,
792 ) -> None:
793 """GET /api/repos/{repo_id}/credits returns 401 without MSign auth."""
794 repo = _make_repo("auth-test-repo")
795 db_session.add(repo)
796 await db_session.commit()
797 await db_session.refresh(repo)
798 response = await client.get(f"/api/repos/{repo.repo_id}/credits")
799 assert response.status_code == 401
800
801
802 async def test_credits_invalid_sort_param(
803 client: AsyncClient,
804 db_session: AsyncSession,
805 auth_headers: StrDict,
806 ) -> None:
807 """GET /api/repos/{repo_id}/credits with invalid sort returns 422."""
808 repo = _make_repo("sort-test")
809 db_session.add(repo)
810 await db_session.commit()
811 await db_session.refresh(repo)
812 response = await client.get(
813 f"/api/repos/{repo.repo_id}/credits?sort=invalid",
814 headers=auth_headers,
815 )
816 assert response.status_code == 422
817
818
819 async def test_credits_aggregation_service_direct(db_session: AsyncSession) -> None:
820 """musehub_credits.aggregate_credits() returns correct data without HTTP layer."""
821 from musehub.services import musehub_credits
822
823 repo = _make_repo("direct-test")
824 db_session.add(repo)
825 await db_session.flush()
826 repo_id = str(repo.repo_id)
827
828 now = datetime.now(tz=timezone.utc)
829 db_session.add(MusehubCommit(commit_id="svc-001", branch="main", parent_ids=[], message="produce and mix the drop", author="Charlie", timestamp=now))
830 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="svc-001"))
831 await db_session.commit()
832
833 result = await musehub_credits.aggregate_credits(db_session, repo_id, sort="count")
834 assert result.total_contributors == 1
835 assert result.contributors[0].author == "Charlie"
836 assert result.contributors[0].session_count == 1
837
838
839 # ---------------------------------------------------------------------------
840 # Compare endpoint
841 # ---------------------------------------------------------------------------
842
843
844 async def _make_compare_repo(
845 db_session: AsyncSession,
846 client: AsyncClient,
847 auth_headers: StrDict,
848 ) -> str:
849 """Seed a repo with commits on two branches and return repo_id."""
850 from datetime import datetime, timezone
851
852 create = await client.post(
853 "/api/repos",
854 json={"name": "compare-test", "owner": "testuser", "visibility": "private"},
855 headers=auth_headers,
856 )
857 assert create.status_code == 201
858 repo_id: str = str(create.json()["repoId"])
859
860 now = datetime.now(tz=timezone.utc)
861 db_session.add(MusehubCommit(commit_id="base001", branch="main", parent_ids=[], message="add melody line", author="Alice", timestamp=now))
862 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="base001"))
863 db_session.add(MusehubCommit(commit_id="head001", branch="feature", parent_ids=["base001"], message="add chord progression", author="Bob", timestamp=now))
864 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id="head001"))
865 await db_session.commit()
866 return repo_id
867
868
869 async def test_compare_radar_data(
870 client: AsyncClient,
871 db_session: AsyncSession,
872 auth_headers: StrDict,
873 ) -> None:
874 """GET /api/repos/{id}/compare returns 5 dimension scores."""
875 repo_id = await _make_compare_repo(db_session, client, auth_headers)
876 response = await client.get(
877 f"/api/repos/{repo_id}/compare?base=main&head=feature",
878 headers=auth_headers,
879 )
880 assert response.status_code == 200
881 body = response.json()
882 assert "dimensions" in body
883 assert len(body["dimensions"]) == 5
884 expected_dims = {"melodic", "harmonic", "rhythmic", "structural", "dynamic"}
885 found_dims = {d["dimension"] for d in body["dimensions"]}
886 assert found_dims == expected_dims
887 for dim in body["dimensions"]:
888 assert 0.0 <= dim["score"] <= 1.0
889 assert dim["level"] in ("NONE", "LOW", "MED", "HIGH")
890 assert "overallScore" in body
891 assert 0.0 <= body["overallScore"] <= 1.0
892
893
894 async def test_compare_commit_list(
895 client: AsyncClient,
896 db_session: AsyncSession,
897 auth_headers: StrDict,
898 ) -> None:
899 """Commits unique to head are listed in the compare response."""
900 repo_id = await _make_compare_repo(db_session, client, auth_headers)
901 response = await client.get(
902 f"/api/repos/{repo_id}/compare?base=main&head=feature",
903 headers=auth_headers,
904 )
905 assert response.status_code == 200
906 body = response.json()
907 assert "commits" in body
908 # head001 is on feature but not on main
909 commit_ids = [c["commitId"] for c in body["commits"]]
910 assert "head001" in commit_ids
911 # base001 is on main so should NOT appear as unique to head
912 assert "base001" not in commit_ids
913
914
915 async def test_compare_unknown_ref_422(
916 client: AsyncClient,
917 db_session: AsyncSession,
918 auth_headers: StrDict,
919 ) -> None:
920 """Unknown ref (branch with no commits) returns 422."""
921 create = await client.post(
922 "/api/repos",
923 json={"name": "empty-compare", "owner": "testuser", "visibility": "private"},
924 headers=auth_headers,
925 )
926 assert create.status_code == 201
927 repo_id = create.json()["repoId"]
928 response = await client.get(
929 f"/api/repos/{repo_id}/compare?base=nonexistent&head=alsoabsent",
930 headers=auth_headers,
931 )
932 assert response.status_code == 422
933
934
935 async def test_compare_emotion_diff_fields(
936 client: AsyncClient,
937 db_session: AsyncSession,
938 auth_headers: StrDict,
939 ) -> None:
940 """Compare response includes emotion diff with required delta fields."""
941 repo_id = await _make_compare_repo(db_session, client, auth_headers)
942 response = await client.get(
943 f"/api/repos/{repo_id}/compare?base=main&head=feature",
944 headers=auth_headers,
945 )
946 assert response.status_code == 200
947 body = response.json()
948 assert "emotionDiff" in body
949 ed = body["emotionDiff"]
950 for field in ("energyDelta", "valenceDelta", "tensionDelta", "darknessDelta"):
951 assert field in ed
952 assert -1.0 <= ed[field] <= 1.0
953 for field in ("baseEnergy", "headEnergy", "baseValence", "headValence"):
954 assert field in ed
955 assert 0.0 <= ed[field] <= 1.0
956
957
958
959 # ---------------------------------------------------------------------------
960 # ---------------------------------------------------------------------------
961 # GET /repos/{repo_id}/settings
962 # ---------------------------------------------------------------------------
963
964 TEST_OWNER_USER_ID = compute_identity_id(b"testuser")
965
966
967 async def test_get_repo_settings_returns_defaults(
968 client: AsyncClient,
969 db_session: AsyncSession,
970 auth_headers: StrDict,
971 ) -> None:
972 """GET /repos/{repo_id}/settings returns full settings with canonical defaults."""
973 repo = _make_repo("settings-get-test")
974 db_session.add(repo)
975 await db_session.commit()
976 await db_session.refresh(repo)
977
978 resp = await client.get(
979 f"/api/repos/{repo.repo_id}/settings",
980 headers=auth_headers,
981 )
982 assert resp.status_code == 200
983 body = resp.json()
984 assert body["name"] == "settings-get-test"
985 assert body["visibility"] == "private"
986 assert body["hasIssues"] is True
987 assert body["allowMergeCommit"] is True
988 assert body["allowRebaseMerge"] is False
989 assert body["deleteBranchOnMerge"] is True
990 assert body["defaultBranch"] == "main"
991
992
993 async def test_get_repo_settings_requires_auth(
994 client: AsyncClient,
995 db_session: AsyncSession,
996 ) -> None:
997 """GET /repos/{repo_id}/settings returns 401 without a MSign Authorization header."""
998 repo = _make_repo("settings-noauth")
999 db_session.add(repo)
1000 await db_session.commit()
1001 await db_session.refresh(repo)
1002
1003 resp = await client.get(f"/api/repos/{repo.repo_id}/settings")
1004 assert resp.status_code == 401
1005
1006
1007 async def test_get_repo_settings_returns_403_for_non_admin(
1008 client: AsyncClient,
1009 db_session: AsyncSession,
1010 auth_headers: StrDict,
1011 ) -> None:
1012 """GET /repos/{repo_id}/settings returns 403 when caller is not owner or admin."""
1013 repo = _make_repo("settings-403-test", owner="other-owner", owner_user_id=compute_identity_id(b"other-owner"), visibility="public")
1014 db_session.add(repo)
1015 await db_session.commit()
1016 await db_session.refresh(repo)
1017
1018 resp = await client.get(
1019 f"/api/repos/{repo.repo_id}/settings",
1020 headers=auth_headers,
1021 )
1022 assert resp.status_code == 403
1023
1024
1025 async def test_get_repo_settings_returns_404_for_unknown_repo(
1026 client: AsyncClient,
1027 auth_headers: StrDict,
1028 ) -> None:
1029 """GET /repos/{repo_id}/settings returns 404 for a non-existent repo."""
1030 resp = await client.get(
1031 "/api/repos/nonexistent-repo-id/settings",
1032 headers=auth_headers,
1033 )
1034 assert resp.status_code == 404
1035
1036
1037 # ---------------------------------------------------------------------------
1038 # PATCH /repos/{repo_id}/settings
1039 # ---------------------------------------------------------------------------
1040
1041
1042 async def test_patch_repo_settings_updates_fields(
1043 client: AsyncClient,
1044 db_session: AsyncSession,
1045 auth_headers: StrDict,
1046 ) -> None:
1047 """PATCH /repos/{repo_id}/settings owner can update dedicated and flag fields."""
1048 repo = _make_repo("settings-patch-test")
1049 db_session.add(repo)
1050 await db_session.commit()
1051 await db_session.refresh(repo)
1052
1053 resp = await client.patch(
1054 f"/api/repos/{repo.repo_id}/settings",
1055 json={
1056 "description": "Updated description",
1057 "visibility": "public",
1058 "hasIssues": False,
1059 "allowRebaseMerge": True,
1060 "homepageUrl": "https://muse.app",
1061 "topics": ["classical", "baroque"],
1062 },
1063 headers=auth_headers,
1064 )
1065 assert resp.status_code == 200
1066 body = resp.json()
1067 assert body["description"] == "Updated description"
1068 assert body["visibility"] == "public"
1069 assert body["hasIssues"] is False
1070 assert body["allowRebaseMerge"] is True
1071 assert body["homepageUrl"] == "https://muse.app"
1072 assert body["topics"] == ["classical", "baroque"]
1073 # Untouched field should retain its default
1074 assert body["allowMergeCommit"] is True
1075
1076
1077 async def test_patch_repo_settings_partial_update_preserves_other_fields(
1078 client: AsyncClient,
1079 db_session: AsyncSession,
1080 auth_headers: StrDict,
1081 ) -> None:
1082 """PATCH with a single field leaves all other settings unchanged."""
1083 repo = _make_repo("settings-partial-test")
1084 db_session.add(repo)
1085 await db_session.commit()
1086 await db_session.refresh(repo)
1087
1088 resp = await client.patch(
1089 f"/api/repos/{repo.repo_id}/settings",
1090 json={"defaultBranch": "develop"},
1091 headers=auth_headers,
1092 )
1093 assert resp.status_code == 200
1094 body = resp.json()
1095 assert body["defaultBranch"] == "develop"
1096 # Other fields kept
1097 assert body["name"] == "settings-partial-test"
1098 assert body["visibility"] == "private"
1099 assert body["hasIssues"] is True
1100
1101
1102 async def test_patch_repo_settings_requires_auth(
1103 client: AsyncClient,
1104 db_session: AsyncSession,
1105 ) -> None:
1106 """PATCH /repos/{repo_id}/settings returns 401 without a MSign Authorization header."""
1107 repo = _make_repo("settings-patch-noauth")
1108 db_session.add(repo)
1109 await db_session.commit()
1110 await db_session.refresh(repo)
1111
1112 resp = await client.patch(
1113 f"/api/repos/{repo.repo_id}/settings",
1114 json={"visibility": "public"},
1115 )
1116 assert resp.status_code == 401
1117
1118
1119 async def test_patch_repo_settings_returns_403_for_non_admin(
1120 client: AsyncClient,
1121 db_session: AsyncSession,
1122 auth_headers: StrDict,
1123 ) -> None:
1124 """PATCH /repos/{repo_id}/settings returns 403 when caller is not owner or admin."""
1125 repo = _make_repo("settings-patch-403", owner="other-owner", owner_user_id=compute_identity_id(b"other-owner"), visibility="public")
1126 db_session.add(repo)
1127 await db_session.commit()
1128 await db_session.refresh(repo)
1129
1130 resp = await client.patch(
1131 f"/api/repos/{repo.repo_id}/settings",
1132 json={"hasWiki": True},
1133 headers=auth_headers,
1134 )
1135 assert resp.status_code == 403
1136
1137
1138 # ---------------------------------------------------------------------------
1139 # PATCH /repos/{repo_id}/settings — marketplace_domain_id (musehub#117 Phase 3)
1140 # ---------------------------------------------------------------------------
1141
1142
1143 async def _make_marketplace_domain(db_session: AsyncSession, slug: str = "test-viewer-domain"):
1144 from musehub.services.musehub_domains import create_domain
1145
1146 domain = await create_domain(
1147 db_session,
1148 author_user_id="testuser",
1149 author_slug="testuser",
1150 slug=slug,
1151 display_name="Test Viewer Domain",
1152 description="",
1153 capabilities={"dimensions": [{"name": "notes"}]},
1154 viewer_type="piano_roll",
1155 version="1.0.0",
1156 )
1157 await db_session.commit()
1158 return domain
1159
1160
1161 async def test_patch_marketplace_domain_id_links_and_installs(
1162 client: AsyncClient,
1163 db_session: AsyncSession,
1164 auth_headers: StrDict,
1165 ) -> None:
1166 """Setting marketplace_domain_id links the repo and increments install_count."""
1167 from musehub.services.musehub_domains import get_domain_by_id
1168
1169 domain = await _make_marketplace_domain(db_session, slug="link-domain")
1170 repo = _make_repo("marketplace-link-test", visibility="public")
1171 db_session.add(repo)
1172 await db_session.commit()
1173 await db_session.refresh(repo)
1174
1175 resp = await client.patch(
1176 f"/api/repos/{repo.repo_id}/settings",
1177 json={"marketplaceDomainId": domain.domain_id},
1178 headers=auth_headers,
1179 )
1180 assert resp.status_code == 200
1181 assert resp.json()["marketplaceDomainId"] == domain.domain_id
1182
1183 updated_domain = await get_domain_by_id(db_session, domain.domain_id)
1184 assert updated_domain is not None
1185 assert updated_domain.install_count == 1
1186
1187
1188 async def test_patch_marketplace_domain_id_unknown_returns_404(
1189 client: AsyncClient,
1190 db_session: AsyncSession,
1191 auth_headers: StrDict,
1192 ) -> None:
1193 """Setting marketplace_domain_id to a nonexistent domain returns 404."""
1194 repo = _make_repo("marketplace-404-test", visibility="public")
1195 db_session.add(repo)
1196 await db_session.commit()
1197 await db_session.refresh(repo)
1198
1199 resp = await client.patch(
1200 f"/api/repos/{repo.repo_id}/settings",
1201 json={"marketplaceDomainId": "sha256:doesnotexist"},
1202 headers=auth_headers,
1203 )
1204 assert resp.status_code == 404
1205
1206
1207 async def test_patch_marketplace_domain_id_clear_uninstalls(
1208 client: AsyncClient,
1209 db_session: AsyncSession,
1210 auth_headers: StrDict,
1211 ) -> None:
1212 """Clearing marketplace_domain_id (empty string) unlinks and decrements install_count."""
1213 from musehub.services.musehub_domains import get_domain_by_id
1214
1215 domain = await _make_marketplace_domain(db_session, slug="clear-domain")
1216 repo = _make_repo("marketplace-clear-test", visibility="public")
1217 db_session.add(repo)
1218 await db_session.commit()
1219 await db_session.refresh(repo)
1220
1221 link_resp = await client.patch(
1222 f"/api/repos/{repo.repo_id}/settings",
1223 json={"marketplaceDomainId": domain.domain_id},
1224 headers=auth_headers,
1225 )
1226 assert link_resp.status_code == 200
1227
1228 clear_resp = await client.patch(
1229 f"/api/repos/{repo.repo_id}/settings",
1230 json={"marketplaceDomainId": ""},
1231 headers=auth_headers,
1232 )
1233 assert clear_resp.status_code == 200
1234 assert clear_resp.json()["marketplaceDomainId"] is None
1235
1236 updated_domain = await get_domain_by_id(db_session, domain.domain_id)
1237 assert updated_domain is not None
1238 assert updated_domain.install_count == 0
1239
1240
1241 async def test_patch_marketplace_domain_id_unchanged_does_not_double_install(
1242 client: AsyncClient,
1243 db_session: AsyncSession,
1244 auth_headers: StrDict,
1245 ) -> None:
1246 """Re-sending the same marketplace_domain_id is a no-op, not a second install."""
1247 from musehub.services.musehub_domains import get_domain_by_id
1248
1249 domain = await _make_marketplace_domain(db_session, slug="idempotent-domain")
1250 repo = _make_repo("marketplace-idempotent-test", visibility="public")
1251 db_session.add(repo)
1252 await db_session.commit()
1253 await db_session.refresh(repo)
1254
1255 for _ in range(2):
1256 resp = await client.patch(
1257 f"/api/repos/{repo.repo_id}/settings",
1258 json={"marketplaceDomainId": domain.domain_id},
1259 headers=auth_headers,
1260 )
1261 assert resp.status_code == 200
1262
1263 updated_domain = await get_domain_by_id(db_session, domain.domain_id)
1264 assert updated_domain is not None
1265 assert updated_domain.install_count == 1
1266
1267
1268 # ---------------------------------------------------------------------------
1269 # DELETE /repos/{repo_id} — soft-delete
1270 # ---------------------------------------------------------------------------
1271
1272
1273 async def test_delete_repo_returns_204(
1274 client: AsyncClient,
1275 auth_headers: StrDict,
1276 ) -> None:
1277 """DELETE /repos/{repo_id} soft-deletes a repo owned by the caller and returns 204."""
1278 create = await client.post(
1279 "/api/repos",
1280 json={"name": "to-delete", "owner": "testuser", "visibility": "private"},
1281 headers=auth_headers,
1282 )
1283 assert create.status_code == 201
1284 repo_id = create.json()["repoId"]
1285
1286 resp = await client.delete(f"/api/repos/{repo_id}", headers=auth_headers)
1287 assert resp.status_code == 204
1288
1289
1290 async def test_delete_repo_hides_repo_from_get(
1291 client: AsyncClient,
1292 auth_headers: StrDict,
1293 ) -> None:
1294 """After DELETE, GET /repos/{repo_id} returns 404."""
1295 create = await client.post(
1296 "/api/repos",
1297 json={"name": "hidden-after-delete", "owner": "testuser", "visibility": "private"},
1298 headers=auth_headers,
1299 )
1300 repo_id = create.json()["repoId"]
1301
1302 await client.delete(f"/api/repos/{repo_id}", headers=auth_headers)
1303
1304 get_resp = await client.get(f"/api/repos/{repo_id}", headers=auth_headers)
1305 assert get_resp.status_code == 404
1306
1307
1308 async def test_delete_repo_removes_row_from_db(
1309 client: AsyncClient,
1310 auth_headers: StrDict,
1311 db_session: AsyncSession,
1312 ) -> None:
1313 """DELETE /repos/{repo_id} must hard-delete — no row must remain in the DB."""
1314 from sqlalchemy import select as sa_select
1315 from musehub.db.musehub_repo_models import MusehubRepo
1316
1317 create = await client.post(
1318 "/api/repos",
1319 json={"name": "hard-delete-me", "owner": "testuser", "visibility": "private"},
1320 headers=auth_headers,
1321 )
1322 assert create.status_code == 201
1323 repo_id = create.json()["repoId"]
1324
1325 resp = await client.delete(f"/api/repos/{repo_id}", headers=auth_headers)
1326 assert resp.status_code == 204
1327
1328 row = (
1329 await db_session.execute(
1330 sa_select(MusehubRepo).where(MusehubRepo.repo_id == repo_id)
1331 )
1332 ).scalar_one_or_none()
1333 assert row is None, "Hard delete must remove the row — soft delete is not acceptable"
1334
1335
1336 async def test_delete_repo_requires_auth(
1337 client: AsyncClient,
1338 db_session: AsyncSession,
1339 ) -> None:
1340 """DELETE /repos/{repo_id} returns 401 without a MSign Authorization header."""
1341 repo = _make_repo("delete-noauth", visibility="public")
1342 db_session.add(repo)
1343 await db_session.commit()
1344 await db_session.refresh(repo)
1345
1346 resp = await client.delete(f"/api/repos/{repo.repo_id}")
1347 assert resp.status_code == 401
1348
1349
1350 async def test_delete_repo_returns_403_for_non_owner(
1351 client: AsyncClient,
1352 db_session: AsyncSession,
1353 auth_headers: StrDict,
1354 ) -> None:
1355 """DELETE /repos/{repo_id} returns 403 when caller is not the owner."""
1356 repo = _make_repo("delete-403", owner="other-owner", owner_user_id=compute_identity_id(b"other-owner"), visibility="public")
1357 db_session.add(repo)
1358 await db_session.commit()
1359 await db_session.refresh(repo)
1360
1361 resp = await client.delete(
1362 f"/api/repos/{repo.repo_id}", headers=auth_headers
1363 )
1364 assert resp.status_code == 403
1365
1366
1367 async def test_delete_repo_returns_404_for_unknown_repo(
1368 client: AsyncClient,
1369 auth_headers: StrDict,
1370 ) -> None:
1371 """DELETE /repos/{repo_id} returns 404 for a non-existent repo."""
1372 resp = await client.delete(
1373 "/api/repos/nonexistent-repo-id", headers=auth_headers
1374 )
1375 assert resp.status_code == 404
1376
1377
1378 async def test_delete_repo_service_hard_deletes_row(
1379 db_session: AsyncSession,
1380 ) -> None:
1381 """delete_repo() service hard-deletes the row from the DB."""
1382 repo = await musehub_repository.create_repo(
1383 db_session,
1384 name="svc-delete-test",
1385 owner="testuser",
1386 visibility="private",
1387 owner_user_id=compute_identity_id(b"testuser"),
1388 )
1389 repo_id = repo.repo_id
1390 await db_session.commit()
1391
1392 deleted = await musehub_repository.delete_repo(db_session, repo_id)
1393 await db_session.commit()
1394
1395 assert deleted is True
1396 # Row must be completely gone from the DB
1397 row = await db_session.get(MusehubRepo, repo_id)
1398 assert row is None
1399
1400
1401 async def test_delete_repo_service_returns_false_for_unknown(
1402 db_session: AsyncSession,
1403 ) -> None:
1404 """delete_repo() returns False for a non-existent repo."""
1405 result = await musehub_repository.delete_repo(db_session, "does-not-exist")
1406 assert result is False
1407
1408
1409 # ---------------------------------------------------------------------------
1410 # POST /repos/{repo_id}/transfer — transfer ownership
1411 # ---------------------------------------------------------------------------
1412
1413
1414 async def test_transfer_repo_ownership_returns_200(
1415 client: AsyncClient,
1416 auth_headers: StrDict,
1417 ) -> None:
1418 """POST /repos/{repo_id}/transfer returns 200 with updated ownerUserId."""
1419 create = await client.post(
1420 "/api/repos",
1421 json={"name": "transfer-me", "owner": "testuser", "visibility": "private"},
1422 headers=auth_headers,
1423 )
1424 assert create.status_code == 201
1425 repo_id = create.json()["repoId"]
1426 new_owner = "another-user-id-1234"
1427
1428 resp = await client.post(
1429 f"/api/repos/{repo_id}/transfer",
1430 json={"newOwnerUserId": new_owner},
1431 headers=auth_headers,
1432 )
1433 assert resp.status_code == 200
1434 body = resp.json()
1435 assert body["ownerUserId"] == new_owner
1436 assert body["repoId"] == repo_id
1437
1438
1439 async def test_transfer_repo_requires_auth(
1440 client: AsyncClient,
1441 db_session: AsyncSession,
1442 ) -> None:
1443 """POST /repos/{repo_id}/transfer returns 401 without an MSign token."""
1444 repo = _make_repo("transfer-noauth", visibility="public")
1445 db_session.add(repo)
1446 await db_session.commit()
1447 await db_session.refresh(repo)
1448
1449 resp = await client.post(
1450 f"/api/repos/{repo.repo_id}/transfer",
1451 json={"newOwnerUserId": "new-user-id"},
1452 )
1453 assert resp.status_code == 401
1454
1455
1456 # ---------------------------------------------------------------------------
1457 # Wizard creation endpoint — # ---------------------------------------------------------------------------
1458
1459
1460 async def test_create_repo_wizard_initialize_creates_branch_and_commit(
1461 client: AsyncClient,
1462 auth_headers: StrDict,
1463 db_session: AsyncSession,
1464 ) -> None:
1465 """POST /repos with initialize=true creates a default branch + initial commit."""
1466 resp = await client.post(
1467 "/api/repos",
1468 json={
1469 "name": "wizard-init-repo",
1470 "owner": "testuser",
1471 "visibility": "public",
1472 "initialize": True,
1473 "defaultBranch": "main",
1474 },
1475 headers=auth_headers,
1476 )
1477 assert resp.status_code == 201
1478 repo_id = resp.json()["repoId"]
1479
1480 branches_resp = await client.get(
1481 f"/api/repos/{repo_id}/branches",
1482 headers=auth_headers,
1483 )
1484 assert branches_resp.status_code == 200
1485 branches = branches_resp.json()["branches"]
1486 assert any(b["name"] == "main" for b in branches), "Expected 'main' branch to be created"
1487
1488 commits_resp = await client.get(
1489 f"/api/repos/{repo_id}/commits",
1490 headers=auth_headers,
1491 )
1492 assert commits_resp.status_code == 200
1493 commits = commits_resp.json()["commits"]
1494 assert len(commits) == 1
1495 assert commits[0]["message"] == "Initial commit"
1496
1497
1498 async def test_create_repo_wizard_no_initialize_stays_empty(
1499 client: AsyncClient,
1500 auth_headers: StrDict,
1501 ) -> None:
1502 """POST /repos with initialize=false leaves branches and commits empty."""
1503 resp = await client.post(
1504 "/api/repos",
1505 json={
1506 "name": "wizard-noinit-repo",
1507 "owner": "testuser",
1508 "initialize": False,
1509 },
1510 headers=auth_headers,
1511 )
1512 assert resp.status_code == 201
1513 repo_id = resp.json()["repoId"]
1514
1515 branches_resp = await client.get(
1516 f"/api/repos/{repo_id}/branches",
1517 headers=auth_headers,
1518 )
1519 assert branches_resp.json()["branches"] == []
1520
1521 commits_resp = await client.get(
1522 f"/api/repos/{repo_id}/commits",
1523 headers=auth_headers,
1524 )
1525 assert commits_resp.json()["commits"] == []
1526
1527
1528 async def test_create_repo_wizard_topics_merged_into_tags(
1529 client: AsyncClient,
1530 auth_headers: StrDict,
1531 ) -> None:
1532 """POST /repos with topics merges them into the tag list (deduplicated)."""
1533 resp = await client.post(
1534 "/api/repos",
1535 json={
1536 "name": "topics-test-repo",
1537 "owner": "testuser",
1538 "tags": ["jazz"],
1539 "topics": ["classical", "jazz"], # 'jazz' deduped
1540 "initialize": False,
1541 },
1542 headers=auth_headers,
1543 )
1544 assert resp.status_code == 201
1545 body = resp.json()
1546 tags: list[str] = body["tags"]
1547 assert "jazz" in tags
1548 assert "classical" in tags
1549 assert tags.count("jazz") == 1, "Duplicate 'jazz' must be removed"
1550
1551
1552 async def test_create_repo_wizard_clone_url_uses_https_scheme(
1553 client: AsyncClient,
1554 auth_headers: StrDict,
1555 ) -> None:
1556 """Clone URL returned by POST /repos uses an http(s):// scheme, not musehub://.
1557
1558 musehub:// is not a scheme the muse CLI understands. The clone URL must be
1559 a valid HTTP(S) URL so that `muse clone <url>` works without --hub.
1560 """
1561 resp = await client.post(
1562 "/api/repos",
1563 json={"name": "clone-url-test", "owner": "testuser", "initialize": False},
1564 headers=auth_headers,
1565 )
1566 assert resp.status_code == 201
1567 clone_url: str = resp.json()["cloneUrl"]
1568 assert clone_url.startswith("http"), f"Expected http(s):// prefix, got: {clone_url}"
1569 assert "musehub://" not in clone_url, "musehub:// is not a valid CLI scheme"
1570 assert "testuser" in clone_url
1571
1572
1573 async def test_create_repo_wizard_template_copies_description(
1574 client: AsyncClient,
1575 auth_headers: StrDict,
1576 db_session: AsyncSession,
1577 ) -> None:
1578 """POST /repos with template_repo_id copies description from a public template."""
1579 template = _make_repo(
1580 "template-source",
1581 owner="template-owner",
1582 owner_user_id=compute_identity_id(b"template-owner"),
1583 visibility="public",
1584 description="A great neo-baroque composition template",
1585 tags=["baroque", "piano"],
1586 )
1587 db_session.add(template)
1588 await db_session.commit()
1589 await db_session.refresh(template)
1590 template_id = str(template.repo_id)
1591
1592 resp = await client.post(
1593 "/api/repos",
1594 json={
1595 "name": "from-template-repo",
1596 "owner": "testuser",
1597 "initialize": False,
1598 "templateRepoId": template_id,
1599 },
1600 headers=auth_headers,
1601 )
1602 assert resp.status_code == 201
1603 body = resp.json()
1604 assert body["description"] == "A great neo-baroque composition template"
1605 assert "baroque" in body["tags"]
1606 assert "piano" in body["tags"]
1607
1608
1609 async def test_create_repo_wizard_private_template_not_copied(
1610 client: AsyncClient,
1611 auth_headers: StrDict,
1612 db_session: AsyncSession,
1613 ) -> None:
1614 """Private template repo metadata is NOT copied (must be public)."""
1615 private_template = _make_repo(
1616 "private-template",
1617 owner="secret-owner",
1618 owner_user_id=compute_identity_id(b"secret-owner"),
1619 description="Secret description",
1620 tags=["secret"],
1621 )
1622 db_session.add(private_template)
1623 await db_session.commit()
1624 await db_session.refresh(private_template)
1625 template_id = str(private_template.repo_id)
1626
1627 resp = await client.post(
1628 "/api/repos",
1629 json={
1630 "name": "refused-template-repo",
1631 "owner": "testuser",
1632 "description": "My own description",
1633 "initialize": False,
1634 "templateRepoId": template_id,
1635 },
1636 headers=auth_headers,
1637 )
1638 assert resp.status_code == 201
1639 body = resp.json()
1640 # Private template must not override user's own description
1641 assert body["description"] == "My own description"
1642 assert "secret" not in body["tags"]
1643
1644
1645 async def test_create_repo_wizard_custom_default_branch(
1646 client: AsyncClient,
1647 auth_headers: StrDict,
1648 ) -> None:
1649 """POST /repos with initialize=true and custom defaultBranch creates the right branch."""
1650 resp = await client.post(
1651 "/api/repos",
1652 json={
1653 "name": "custom-branch-repo",
1654 "owner": "testuser",
1655 "initialize": True,
1656 "defaultBranch": "develop",
1657 },
1658 headers=auth_headers,
1659 )
1660 assert resp.status_code == 201
1661 repo_id = resp.json()["repoId"]
1662
1663 branches_resp = await client.get(
1664 f"/api/repos/{repo_id}/branches",
1665 headers=auth_headers,
1666 )
1667 branch_names = [b["name"] for b in branches_resp.json()["branches"]]
1668 assert "develop" in branch_names
1669 assert "main" not in branch_names
1670
1671
1672 # ---------------------------------------------------------------------------
1673 # GET /repos — list repos for authenticated user
1674 # ---------------------------------------------------------------------------
1675
1676
1677 async def test_list_my_repos_returns_owned_repos(
1678 client: AsyncClient,
1679 auth_headers: StrDict,
1680 ) -> None:
1681 """GET /repos returns repos created by the authenticated user."""
1682 # Create two repos
1683 for name in ("owned-repo-a", "owned-repo-b"):
1684 await client.post(
1685 "/api/repos",
1686 json={"name": name, "owner": "testuser", "initialize": False},
1687 headers=auth_headers,
1688 )
1689
1690 resp = await client.get("/api/repos", headers=auth_headers)
1691 assert resp.status_code == 200
1692 body = resp.json()
1693 assert "repos" in body
1694 assert "total" in body
1695 assert "nextCursor" in body
1696 names = [r["name"] for r in body["repos"]]
1697 assert "owned-repo-a" in names
1698 assert "owned-repo-b" in names
1699
1700
1701 async def test_list_my_repos_requires_auth(client: AsyncClient) -> None:
1702 """GET /repos returns 401 without an MSign token."""
1703 resp = await client.get("/api/repos")
1704 assert resp.status_code == 401
1705
1706
1707 async def test_transfer_repo_returns_403_for_non_owner(
1708 client: AsyncClient,
1709 db_session: AsyncSession,
1710 auth_headers: StrDict,
1711 ) -> None:
1712 """POST /repos/{repo_id}/transfer returns 403 when caller is not the owner."""
1713 repo = _make_repo("transfer-403", owner="other-owner", owner_user_id=compute_identity_id(b"other-owner"), visibility="public")
1714 db_session.add(repo)
1715 await db_session.commit()
1716 await db_session.refresh(repo)
1717
1718 resp = await client.post(
1719 f"/api/repos/{repo.repo_id}/transfer",
1720 json={"newOwnerUserId": "attacker-user-id"},
1721 headers=auth_headers,
1722 )
1723 assert resp.status_code == 403
1724
1725
1726 async def test_transfer_repo_returns_404_for_unknown_repo(
1727 client: AsyncClient,
1728 auth_headers: StrDict,
1729 ) -> None:
1730 """POST /repos/{repo_id}/transfer returns 404 for a non-existent repo."""
1731 resp = await client.post(
1732 "/api/repos/nonexistent-repo-id/transfer",
1733 json={"newOwnerUserId": "some-user"},
1734 headers=auth_headers,
1735 )
1736 assert resp.status_code == 404
1737
1738
1739 async def test_transfer_repo_service_updates_owner_user_id(
1740 db_session: AsyncSession,
1741 ) -> None:
1742 """transfer_repo_ownership() service updates owner_user_id on the row."""
1743 _new_owner_id = compute_identity_id(b"new-owner")
1744 repo = await musehub_repository.create_repo(
1745 db_session,
1746 name="svc-transfer-test",
1747 owner="testuser",
1748 visibility="private",
1749 owner_user_id=compute_identity_id(b"original-owner"),
1750 )
1751 await db_session.commit()
1752
1753 updated = await musehub_repository.transfer_repo_ownership(
1754 db_session, repo.repo_id, _new_owner_id
1755 )
1756 await db_session.commit()
1757
1758 assert updated is not None
1759 assert updated.owner_user_id == _new_owner_id
1760 # Verify persisted
1761 fetched = await musehub_repository.get_repo(db_session, repo.repo_id)
1762 assert fetched is not None
1763 assert fetched.owner_user_id == _new_owner_id
1764
1765
1766 async def test_transfer_repo_service_returns_none_for_unknown(
1767 db_session: AsyncSession,
1768 ) -> None:
1769 """transfer_repo_ownership() returns None for a non-existent repo."""
1770 result = await musehub_repository.transfer_repo_ownership(
1771 db_session, "does-not-exist", "new-owner"
1772 )
1773 assert result is None
1774
1775
1776 # ---------------------------------------------------------------------------
1777 # GET /repos — list repos for authenticated user
1778 # ---------------------------------------------------------------------------
1779
1780
1781 async def test_list_my_repos_total_matches_count(
1782 client: AsyncClient,
1783 auth_headers: StrDict,
1784 ) -> None:
1785 """total field in GET /repos matches the number of repos created."""
1786 initial = await client.get("/api/repos", headers=auth_headers)
1787 initial_total: int = initial.json()["total"]
1788
1789 await client.post(
1790 "/api/repos",
1791 json={"name": "total-count-test", "owner": "testuser", "initialize": False},
1792 headers=auth_headers,
1793 )
1794
1795 resp = await client.get("/api/repos", headers=auth_headers)
1796 assert resp.status_code == 200
1797 assert resp.json()["total"] == initial_total + 1
1798
1799
1800 async def test_list_my_repos_pagination_cursor(
1801 client: AsyncClient,
1802 auth_headers: StrDict,
1803 db_session: AsyncSession,
1804 ) -> None:
1805 """GET /repos with limit=1 returns a nextCursor that fetches the next page."""
1806 from datetime import timedelta
1807
1808 now = datetime.now(tz=timezone.utc)
1809 for i in range(3):
1810 slug = f"paged-repo-{i}"
1811 created_at = now - timedelta(seconds=i)
1812 repo = MusehubRepo(
1813 repo_id=compute_repo_id(TEST_OWNER_USER_ID, slug, "code", created_at.isoformat()),
1814 name=slug,
1815 owner="testuser",
1816 slug=slug,
1817 visibility="public",
1818 owner_user_id=TEST_OWNER_USER_ID,
1819 created_at=created_at,
1820 updated_at=created_at,
1821 )
1822 db_session.add(repo)
1823 await db_session.commit()
1824
1825 first_page = await client.get(
1826 "/api/repos?limit=1",
1827 headers=auth_headers,
1828 )
1829 assert first_page.status_code == 200
1830 body = first_page.json()
1831 assert len(body["repos"]) == 1
1832 next_cursor = body["nextCursor"]
1833 assert next_cursor is not None
1834
1835 second_page = await client.get(
1836 f"/api/repos?limit=1&cursor={next_cursor}",
1837 headers=auth_headers,
1838 )
1839 assert second_page.status_code == 200
1840 second_body = second_page.json()
1841 assert len(second_body["repos"]) == 1
1842 # Pages must not overlap
1843 first_id = body["repos"][0]["repoId"]
1844 second_id = second_body["repos"][0]["repoId"]
1845 assert first_id != second_id
1846
1847
1848 async def test_list_my_repos_service_direct(db_session: AsyncSession) -> None:
1849 """list_repos_for_user() returns only repos owned by the given user."""
1850 from musehub.services.musehub_repository import list_repos_for_user
1851
1852 owner_handle = "user-list-direct"
1853 other_handle = "user-other-direct"
1854
1855 repo_mine = _make_repo("mine-direct", owner=owner_handle, owner_user_id=compute_identity_id(owner_handle.encode()))
1856 repo_other = _make_repo("not-mine-direct", owner=other_handle, owner_user_id=compute_identity_id(other_handle.encode()))
1857 db_session.add_all([repo_mine, repo_other])
1858 await db_session.commit()
1859
1860 result = await list_repos_for_user(db_session, owner_handle)
1861 repo_ids = {r.repo_id for r in result.repos}
1862 assert str(repo_mine.repo_id) in repo_ids
1863 assert str(repo_other.repo_id) not in repo_ids
1864
1865
1866 # ---------------------------------------------------------------------------
1867 # GET /repos/{repo_id}/collaborators/{username}/permission
1868 # ---------------------------------------------------------------------------
1869
1870
1871 async def test_collab_access_owner_returns_owner_permission(
1872 client: AsyncClient,
1873 db_session: AsyncSession,
1874 auth_headers: StrDict,
1875 ) -> None:
1876 """Owner's username returns permission='owner' with accepted_at=null."""
1877 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1878
1879 owner_id = TEST_OWNER_USER_ID
1880 repo = _make_repo("access-owner-test")
1881 db_session.add(repo)
1882 await db_session.commit()
1883 await db_session.refresh(repo)
1884
1885 resp = await client.get(
1886 f"/api/repos/{repo.repo_id}/collaborators/{owner_id}/permission",
1887 headers=auth_headers,
1888 )
1889 assert resp.status_code == 200
1890 body = resp.json()
1891 assert body["username"] == owner_id
1892 assert body["permission"] == "owner"
1893 assert body["acceptedAt"] is None
1894
1895
1896 async def test_collab_access_collaborator_returns_permission(
1897 client: AsyncClient,
1898 db_session: AsyncSession,
1899 auth_headers: StrDict,
1900 ) -> None:
1901 """A known collaborator returns their permission level and accepted_at."""
1902 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1903
1904 owner_id = TEST_OWNER_USER_ID
1905 collab_user_id = "collab-user-write"
1906
1907 repo = _make_repo("access-collab-test")
1908 db_session.add(repo)
1909 await db_session.commit()
1910 await db_session.refresh(repo)
1911
1912 accepted = datetime(2026, 1, 10, 10, 0, 0, tzinfo=timezone.utc)
1913 _rid = str(repo.repo_id)
1914 collab = MusehubCollaborator(
1915 id=compute_collaborator_id(_rid, collab_user_id, accepted.isoformat()),
1916 repo_id=_rid,
1917 identity_handle=collab_user_id,
1918 permission="write",
1919 accepted_at=accepted,
1920 )
1921 db_session.add(collab)
1922 await db_session.commit()
1923
1924 resp = await client.get(
1925 f"/api/repos/{repo.repo_id}/collaborators/{collab_user_id}/permission",
1926 headers=auth_headers,
1927 )
1928 assert resp.status_code == 200
1929 body = resp.json()
1930 assert body["username"] == collab_user_id
1931 assert body["permission"] == "write"
1932 assert body["acceptedAt"] is not None
1933
1934
1935 async def test_collab_access_non_collaborator_returns_404(
1936 client: AsyncClient,
1937 db_session: AsyncSession,
1938 auth_headers: StrDict,
1939 ) -> None:
1940 """A user who is not a collaborator returns 404 with an informative message."""
1941 repo = _make_repo("access-404-test")
1942 db_session.add(repo)
1943 await db_session.commit()
1944 await db_session.refresh(repo)
1945
1946 stranger = "total-stranger-user"
1947 resp = await client.get(
1948 f"/api/repos/{repo.repo_id}/collaborators/{stranger}/permission",
1949 headers=auth_headers,
1950 )
1951 assert resp.status_code == 404
1952 assert stranger in resp.json()["detail"]
1953
1954
1955 async def test_collab_access_unknown_repo_returns_404(
1956 client: AsyncClient,
1957 auth_headers: StrDict,
1958 ) -> None:
1959 """Querying an unknown repo_id returns 404."""
1960 resp = await client.get(
1961 "/api/repos/nonexistent-repo/collaborators/anyone/permission",
1962 headers=auth_headers,
1963 )
1964 assert resp.status_code == 404
1965
1966
1967 async def test_collab_access_requires_auth(
1968 client: AsyncClient,
1969 db_session: AsyncSession,
1970 ) -> None:
1971 """GET /collaborators/{username}/permission returns 401 without an MSign token."""
1972 repo = _make_repo("access-auth-test", visibility="public")
1973 db_session.add(repo)
1974 await db_session.commit()
1975 await db_session.refresh(repo)
1976
1977 resp = await client.get(
1978 f"/api/repos/{repo.repo_id}/collaborators/anyone/permission"
1979 )
1980 assert resp.status_code == 401
1981
1982
1983 async def test_collab_access_admin_permission(
1984 client: AsyncClient,
1985 db_session: AsyncSession,
1986 auth_headers: StrDict,
1987 ) -> None:
1988 """A collaborator with admin permission returns permission='admin'."""
1989 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1990
1991 repo = _make_repo("access-admin-test")
1992 db_session.add(repo)
1993 await db_session.commit()
1994 await db_session.refresh(repo)
1995
1996 admin_user = "admin-collab-user"
1997 _rid = str(repo.repo_id)
1998 _now = datetime.now(tz=timezone.utc)
1999 collab = MusehubCollaborator(
2000 id=compute_collaborator_id(_rid, admin_user, _now.isoformat()),
2001 repo_id=_rid,
2002 identity_handle=admin_user,
2003 permission="admin",
2004 accepted_at=None,
2005 )
2006 db_session.add(collab)
2007 await db_session.commit()
2008
2009 resp = await client.get(
2010 f"/api/repos/{repo.repo_id}/collaborators/{admin_user}/permission",
2011 headers=auth_headers,
2012 )
2013 assert resp.status_code == 200
2014 body = resp.json()
2015 assert body["permission"] == "admin"
File History 15 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 9 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:31491a5f31832312965ba9c8d73c9eb6171069015bde3a471acc9d5006c1e45a revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago