gabriel / musehub public
test_musehub_proposals.py python
1,704 lines 62.0 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
1 """Tests for MuseHub merge proposal endpoints.
2
3 Covers every acceptance criterion from issues #41, #215:
4 - POST /repos/{repo_id}/proposals creates proposal in open state
5 - 422 when from_branch == to_branch
6 - 404 when from_branch does not exist
7 - GET /proposals returns all proposals (open + merged + closed)
8 - GET /proposals/{proposal_id} returns full proposal detail; 404 if not found
9 - GET /proposals/{proposal_id}/diff returns five-dimension musical diff scores
10 - GET /proposals/{proposal_id}/diff graceful degradation when branches have no commits
11 - POST /proposals/{proposal_id}/merge creates merge commit, sets state merged
12 - POST /proposals/{proposal_id}/merge accepts squash and rebase strategies
13 - 409 when merging an already-merged proposal
14 - All endpoints require valid MSign auth
15 - affected_sections derived from commit message text, not structural score heuristic
16 - build_proposal_diff_response / build_zero_diff_response service helpers produce valid output
17
18 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
19 fixtures from conftest.py.
20 """
21 from __future__ import annotations
22
23 from datetime import datetime, timezone
24
25 from muse.core.types import fake_id
26
27 import msgpack
28 import pytest
29 from httpx import AsyncClient
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_proposal_id, compute_repo_id
33 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubSnapshot
34 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
35 from musehub.types.json_types import JSONObject, StrDict
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42
43 async def _create_repo(
44 client: AsyncClient,
45 auth_headers: StrDict,
46 name: str = "neo-soul-repo",
47 ) -> str:
48 """Create a repo via the API and return its repo_id."""
49 response = await client.post(
50 "/api/repos",
51 json={"name": name, "owner": "testuser", "initialize": False},
52 headers=auth_headers,
53 )
54 assert response.status_code == 201
55 return str(response.json()["repoId"])
56
57
58 async def _push_branch(
59 db: AsyncSession,
60 repo_id: str,
61 branch_name: str,
62 ) -> str:
63 """Insert a branch with one commit so the branch exists and has a head commit.
64
65 Returns the commit_id so callers can reference it if needed.
66 """
67 commit_id = fake_id(f"{repo_id}{branch_name}")
68 commit = MusehubCommit(
69 commit_id=commit_id,
70 branch=branch_name,
71 parent_ids=[],
72 message=f"Initial commit on {branch_name}",
73 author="rene",
74 timestamp=datetime.now(tz=timezone.utc),
75 )
76 branch = MusehubBranch(
77 branch_id=compute_branch_id(repo_id, branch_name),
78 repo_id=repo_id,
79 name=branch_name,
80 head_commit_id=commit_id,
81 )
82 db.add(commit)
83 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
84 db.add(branch)
85 await db.commit()
86 return commit_id
87
88
89 async def _create_proposal_helper(
90 client: AsyncClient,
91 auth_headers: StrDict,
92 repo_id: str,
93 *,
94 title: str = "Add neo-soul keys variation",
95 from_branch: str = "feature",
96 to_branch: str = "main",
97 body: str = "",
98 ) -> JSONObject:
99 response = await client.post(
100 f"/api/repos/{repo_id}/proposals",
101 json={
102 "title": title,
103 "fromBranch": from_branch,
104 "toBranch": to_branch,
105 "body": body,
106 },
107 headers=auth_headers,
108 )
109 assert response.status_code == 201, response.text
110 return dict(response.json())
111
112
113 # ---------------------------------------------------------------------------
114 # POST /repos/{repo_id}/proposals
115 # ---------------------------------------------------------------------------
116
117
118 async def test_create_proposal_returns_open_state(
119 client: AsyncClient,
120 auth_headers: StrDict,
121 db_session: AsyncSession,
122 ) -> None:
123 """Proposal created via POST returns state='open' with all required fields."""
124 repo_id = await _create_repo(client, auth_headers, "proposal-open-state-repo")
125 await _push_branch(db_session, repo_id, "feature")
126
127 response = await client.post(
128 f"/api/repos/{repo_id}/proposals",
129 json={
130 "title": "Add neo-soul keys variation",
131 "fromBranch": "feature",
132 "toBranch": "main",
133 "body": "Adds dreamy chord voicings.",
134 },
135 headers=auth_headers,
136 )
137
138 assert response.status_code == 201
139 body = response.json()
140 assert body["state"] == "open"
141 assert body["title"] == "Add neo-soul keys variation"
142 assert body["fromBranch"] == "feature"
143 assert body["toBranch"] == "main"
144 assert body["body"] == "Adds dreamy chord voicings."
145 assert "proposalId" in body
146 assert "createdAt" in body
147 assert body["mergeCommitId"] is None
148
149
150 async def test_create_proposal_same_branch_returns_422(
151 client: AsyncClient,
152 auth_headers: StrDict,
153 ) -> None:
154 """Creating a proposal with from_branch == to_branch returns HTTP 422."""
155 repo_id = await _create_repo(client, auth_headers, "same-branch-repo")
156
157 response = await client.post(
158 f"/api/repos/{repo_id}/proposals",
159 json={"title": "Bad proposal", "fromBranch": "main", "toBranch": "main"},
160 headers=auth_headers,
161 )
162
163 assert response.status_code == 422
164
165
166 async def test_create_proposal_missing_from_branch_returns_404(
167 client: AsyncClient,
168 auth_headers: StrDict,
169 ) -> None:
170 """Creating a proposal when from_branch does not exist returns HTTP 404."""
171 repo_id = await _create_repo(client, auth_headers, "no-branch-repo")
172
173 response = await client.post(
174 f"/api/repos/{repo_id}/proposals",
175 json={"title": "Ghost proposal", "fromBranch": "nonexistent", "toBranch": "main"},
176 headers=auth_headers,
177 )
178
179 assert response.status_code == 404
180
181
182 async def test_create_proposal_requires_auth(client: AsyncClient) -> None:
183 """POST /proposals returns 401 without a MSign Authorization header."""
184 response = await client.post(
185 "/api/repos/any-id/proposals",
186 json={"title": "Unauthorized", "fromBranch": "feat", "toBranch": "main"},
187 )
188 assert response.status_code == 401
189
190
191 # ---------------------------------------------------------------------------
192 # GET /repos/{repo_id}/proposals
193 # ---------------------------------------------------------------------------
194
195
196 async def test_list_proposals_returns_all_states(
197 client: AsyncClient,
198 auth_headers: StrDict,
199 db_session: AsyncSession,
200 ) -> None:
201 """GET /proposals returns open AND merged proposals by default."""
202 repo_id = await _create_repo(client, auth_headers, "list-all-states-repo")
203 await _push_branch(db_session, repo_id, "feature-a")
204 await _push_branch(db_session, repo_id, "feature-b")
205 await _push_branch(db_session, repo_id, "main")
206
207 proposal_a = await _create_proposal_helper(
208 client, auth_headers, repo_id, title="Open proposal", from_branch="feature-a"
209 )
210 proposal_b = await _create_proposal_helper(
211 client, auth_headers, repo_id, title="Merged proposal", from_branch="feature-b"
212 )
213
214 # Merge proposal_b
215 await client.post(
216 f"/api/repos/{repo_id}/proposals/{proposal_b['proposalId']}/merge",
217 json={"mergeStrategy": "merge_commit"},
218 headers=auth_headers,
219 )
220
221 response = await client.get(
222 f"/api/repos/{repo_id}/proposals",
223 headers=auth_headers,
224 )
225 assert response.status_code == 200
226 all_proposals = response.json()["proposals"]
227 assert len(all_proposals) == 2
228 states = {p["state"] for p in all_proposals}
229 assert "open" in states
230 assert "merged" in states
231
232
233 async def test_list_proposals_filter_by_open(
234 client: AsyncClient,
235 auth_headers: StrDict,
236 db_session: AsyncSession,
237 ) -> None:
238 """GET /proposals?state=open returns only open proposals."""
239 repo_id = await _create_repo(client, auth_headers, "filter-open-repo")
240 await _push_branch(db_session, repo_id, "feat-open")
241 await _push_branch(db_session, repo_id, "feat-merge")
242 await _push_branch(db_session, repo_id, "main")
243
244 await _create_proposal_helper(client, auth_headers, repo_id, title="Open proposal", from_branch="feat-open")
245 proposal_to_merge = await _create_proposal_helper(
246 client, auth_headers, repo_id, title="Will merge", from_branch="feat-merge"
247 )
248 await client.post(
249 f"/api/repos/{repo_id}/proposals/{proposal_to_merge['proposalId']}/merge",
250 json={"mergeStrategy": "merge_commit"},
251 headers=auth_headers,
252 )
253
254 response = await client.get(
255 f"/api/repos/{repo_id}/proposals?state=open",
256 headers=auth_headers,
257 )
258 assert response.status_code == 200
259 open_proposals = response.json()["proposals"]
260 assert len(open_proposals) == 1
261 assert open_proposals[0]["state"] == "open"
262
263
264 async def test_list_proposals_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
265 """GET /proposals returns 404 for non-existent repo without a token.
266
267 Uses optional_token — auth is visibility-based; missing repo → 404.
268 """
269 response = await client.get("/api/repos/non-existent-repo-id/proposals")
270 assert response.status_code == 404
271
272
273 # ---------------------------------------------------------------------------
274 # GET /repos/{repo_id}/proposals/{proposal_id}
275 # ---------------------------------------------------------------------------
276
277
278 async def test_get_proposal_returns_full_detail(
279 client: AsyncClient,
280 auth_headers: StrDict,
281 db_session: AsyncSession,
282 ) -> None:
283 """GET /proposals/{proposal_id} returns the full proposal object."""
284 repo_id = await _create_repo(client, auth_headers, "get-detail-repo")
285 await _push_branch(db_session, repo_id, "keys-variation")
286
287 created = await _create_proposal_helper(
288 client,
289 auth_headers,
290 repo_id,
291 title="Keys variation",
292 from_branch="keys-variation",
293 body="Dreamy neo-soul voicings",
294 )
295
296 response = await client.get(
297 f"/api/repos/{repo_id}/proposals/{created['proposalId']}",
298 headers=auth_headers,
299 )
300 assert response.status_code == 200
301 body = response.json()
302 assert body["proposalId"] == created["proposalId"]
303 assert body["title"] == "Keys variation"
304 assert body["body"] == "Dreamy neo-soul voicings"
305 assert body["state"] == "open"
306
307
308 async def test_get_proposal_unknown_id_returns_404(
309 client: AsyncClient,
310 auth_headers: StrDict,
311 ) -> None:
312 """GET /proposals/{unknown_proposal_id} returns 404."""
313 repo_id = await _create_repo(client, auth_headers, "get-404-repo")
314
315 response = await client.get(
316 f"/api/repos/{repo_id}/proposals/does-not-exist",
317 headers=auth_headers,
318 )
319 assert response.status_code == 404
320
321
322 async def test_get_proposal_nonexistent_returns_404_without_auth(client: AsyncClient) -> None:
323 """GET /proposals/{proposal_id} returns 404 for non-existent resource without a token.
324
325 Uses optional_token — auth is visibility-based; missing repo/proposal → 404.
326 """
327 response = await client.get("/api/repos/non-existent-repo/proposals/non-existent-proposal")
328 assert response.status_code == 404
329
330
331 # ---------------------------------------------------------------------------
332 # POST /repos/{repo_id}/proposals/{proposal_id}/merge
333 # ---------------------------------------------------------------------------
334
335
336 async def test_merge_proposal_creates_merge_commit(
337 client: AsyncClient,
338 auth_headers: StrDict,
339 db_session: AsyncSession,
340 ) -> None:
341 """Merging a proposal creates a merge commit and sets state to 'merged'."""
342 repo_id = await _create_repo(client, auth_headers, "merge-commit-repo")
343 await _push_branch(db_session, repo_id, "neo-soul")
344 await _push_branch(db_session, repo_id, "main")
345
346 p = await _create_proposal_helper(
347 client, auth_headers, repo_id, title="Neo-soul merge", from_branch="neo-soul"
348 )
349
350 response = await client.post(
351 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
352 json={"mergeStrategy": "merge_commit"},
353 headers=auth_headers,
354 )
355
356 assert response.status_code == 200
357 body = response.json()
358 assert body["merged"] is True
359 assert "mergeCommitId" in body
360 assert body["mergeCommitId"] is not None
361
362 # Verify proposal state changed to merged
363 detail = await client.get(
364 f"/api/repos/{repo_id}/proposals/{p['proposalId']}",
365 headers=auth_headers,
366 )
367 assert detail.json()["state"] == "merged"
368 assert detail.json()["mergeCommitId"] == body["mergeCommitId"]
369
370
371 async def test_merge_already_merged_returns_409(
372 client: AsyncClient,
373 auth_headers: StrDict,
374 db_session: AsyncSession,
375 ) -> None:
376 """Merging an already-merged proposal returns HTTP 409 Conflict."""
377 repo_id = await _create_repo(client, auth_headers, "double-merge-repo")
378 await _push_branch(db_session, repo_id, "feature-dup")
379 await _push_branch(db_session, repo_id, "main")
380
381 p = await _create_proposal_helper(
382 client, auth_headers, repo_id, title="Duplicate merge", from_branch="feature-dup"
383 )
384
385 # First merge succeeds
386 first = await client.post(
387 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
388 json={"mergeStrategy": "merge_commit"},
389 headers=auth_headers,
390 )
391 assert first.status_code == 200
392
393 # Second merge must 409
394 second = await client.post(
395 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
396 json={"mergeStrategy": "merge_commit"},
397 headers=auth_headers,
398 )
399 assert second.status_code == 409
400
401
402 async def test_merge_proposal_requires_auth(client: AsyncClient) -> None:
403 """POST /proposals/{proposal_id}/merge returns 401 without a MSign Authorization header."""
404 response = await client.post(
405 "/api/repos/r/proposals/p/merge",
406 json={"mergeStrategy": "merge_commit"},
407 )
408 assert response.status_code == 401
409
410
411 async def test_merge_proposal_forbidden_for_non_owner(
412 client: AsyncClient,
413 auth_headers: StrDict,
414 db_session: AsyncSession,
415 ) -> None:
416 """POST /proposals/{proposal_id}/merge as a non-owner returns 403."""
417 from musehub.db.musehub_repo_models import MusehubRepo
418
419 _ot = datetime.now(tz=timezone.utc)
420 _oid = compute_identity_id(b"other-owner")
421 other_repo = MusehubRepo(
422 repo_id=compute_repo_id(_oid, "private-merge-repo", "code", _ot.isoformat()),
423 name="private-merge-repo",
424 owner="other-owner",
425 slug="private-merge-repo",
426 visibility="private",
427 owner_user_id=_oid,
428 created_at=_ot,
429 updated_at=_ot,
430 )
431 db_session.add(other_repo)
432 await db_session.commit()
433
434 response = await client.post(
435 f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/merge",
436 json={"mergeStrategy": "merge_commit"},
437 headers=auth_headers,
438 )
439 assert response.status_code == 403
440
441
442 # ---------------------------------------------------------------------------
443 # Regression tests — author field on proposal
444 # ---------------------------------------------------------------------------
445
446
447 async def test_create_proposal_author_in_response(
448 client: AsyncClient,
449 auth_headers: StrDict,
450 db_session: AsyncSession,
451 ) -> None:
452 """POST /proposals response includes the author field (caller handle) — regression f."""
453 repo_id = await _create_repo(client, auth_headers, "author-proposal-repo")
454 await _push_branch(db_session, repo_id, "feat/author-test")
455 response = await client.post(
456 f"/api/repos/{repo_id}/proposals",
457 json={
458 "title": "Author field regression",
459 "body": "",
460 "fromBranch": "feat/author-test",
461 "toBranch": "main",
462 },
463 headers=auth_headers,
464 )
465 assert response.status_code == 201
466 body = response.json()
467 assert "author" in body
468 assert isinstance(body["author"], str)
469
470
471 async def test_create_proposal_author_persisted_in_list(
472 client: AsyncClient,
473 auth_headers: StrDict,
474 db_session: AsyncSession,
475 ) -> None:
476 """Author field is persisted and returned in the proposal list endpoint — regression f."""
477 repo_id = await _create_repo(client, auth_headers, "author-proposal-list-repo")
478 await _push_branch(db_session, repo_id, "feat/author-list-test")
479 await client.post(
480 f"/api/repos/{repo_id}/proposals",
481 json={
482 "title": "Authored proposal",
483 "body": "",
484 "fromBranch": "feat/author-list-test",
485 "toBranch": "main",
486 },
487 headers=auth_headers,
488 )
489 list_response = await client.get(
490 f"/api/repos/{repo_id}/proposals",
491 headers=auth_headers,
492 )
493 assert list_response.status_code == 200
494 items = list_response.json()["proposals"]
495 assert len(items) == 1
496 assert "author" in items[0]
497 assert isinstance(items[0]["author"], str)
498
499
500 async def test_proposal_diff_endpoint_returns_five_dimensions(
501 client: AsyncClient,
502 auth_headers: StrDict,
503 db_session: AsyncSession,
504 ) -> None:
505 """GET /proposals/{proposal_id}/diff returns per-dimension scores for the proposal branches."""
506 repo_id = await _create_repo(client, auth_headers, "diff-proposal-repo")
507 await _push_branch(db_session, repo_id, "feat/jazz-keys")
508 proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/jazz-keys", to_branch="main")
509 proposal_id = proposal_resp["proposalId"]
510
511 response = await client.get(
512 f"/api/repos/{repo_id}/proposals/{proposal_id}/diff",
513 headers=auth_headers,
514 )
515 assert response.status_code == 200
516 data = response.json()
517 assert "dimensions" in data
518 assert len(data["dimensions"]) == 5
519 assert data["proposalId"] == proposal_id
520 assert data["fromBranch"] == "feat/jazz-keys"
521 assert data["toBranch"] == "main"
522 assert "overallScore" in data
523 assert isinstance(data["overallScore"], float)
524
525 # Every dimension must have the expected fields
526 for dim in data["dimensions"]:
527 assert "dimension" in dim
528 assert dim["dimension"] in ("melodic", "harmonic", "rhythmic", "structural", "dynamic")
529 assert "score" in dim
530 assert 0.0 <= dim["score"] <= 1.0
531 assert "level" in dim
532 assert dim["level"] in ("NONE", "LOW", "MED", "HIGH")
533 assert "deltaLabel" in dim
534 assert "fromBranchCommits" in dim
535 assert "toBranchCommits" in dim
536
537
538 async def test_proposal_diff_endpoint_404_for_unknown_proposal(
539 client: AsyncClient,
540 auth_headers: StrDict,
541 db_session: AsyncSession,
542 ) -> None:
543 """GET /proposals/{proposal_id}/diff returns 404 when the proposal does not exist."""
544 repo_id = await _create_repo(client, auth_headers, "diff-404-repo")
545 response = await client.get(
546 f"/api/repos/{repo_id}/proposals/nonexistent-proposal-id/diff",
547 headers=auth_headers,
548 )
549 assert response.status_code == 404
550
551
552 async def test_proposal_diff_endpoint_graceful_when_no_commits(
553 client: AsyncClient,
554 auth_headers: StrDict,
555 db_session: AsyncSession,
556 ) -> None:
557 """Diff endpoint returns zero scores when branches have no commits (graceful degradation).
558
559 When from_branch has commits but to_branch ('main') has none, compute_hub_divergence
560 raises ValueError. The diff endpoint must catch it and return zero-score placeholders
561 so the proposal detail page always renders.
562 """
563 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit
564 from musehub.db.musehub_social_models import MusehubProposal
565
566 repo_id = await _create_repo(client, auth_headers, "diff-empty-repo")
567
568 # Seed from_branch with a commit so the proposal can be created.
569 _grace_branch = "feat/empty-grace"
570 commit_id = fake_id(f"{repo_id}{_grace_branch}")
571 commit = MusehubCommit(
572 commit_id=commit_id,
573 branch=_grace_branch,
574 parent_ids=[],
575 message=f"Initial commit on {_grace_branch}",
576 author="musician",
577 timestamp=datetime.now(tz=timezone.utc),
578 )
579 branch = MusehubBranch(
580 branch_id=compute_branch_id(repo_id, _grace_branch),
581 repo_id=repo_id,
582 name=_grace_branch,
583 head_commit_id=commit_id,
584 )
585 db_session.add(commit)
586 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
587 db_session.add(branch)
588
589 # to_branch 'main' deliberately has NO commits — divergence will raise ValueError.
590 _grace_now = datetime.now(tz=timezone.utc)
591 _grace_author_id = compute_identity_id(b"musician")
592 proposal = MusehubProposal(
593 proposal_id=compute_proposal_id(repo_id, _grace_author_id, _grace_branch, "main", _grace_now.isoformat()),
594 repo_id=repo_id,
595 proposal_number=1,
596 title="Grace proposal",
597 body="",
598 state="open",
599 from_branch=_grace_branch,
600 to_branch="main",
601 author="musician",
602 )
603 db_session.add(proposal)
604 await db_session.flush()
605 await db_session.refresh(proposal)
606 proposal_id = proposal.proposal_id
607 await db_session.commit()
608
609 response = await client.get(
610 f"/api/repos/{repo_id}/proposals/{proposal_id}/diff",
611 headers=auth_headers,
612 )
613 assert response.status_code == 200
614 data = response.json()
615 assert len(data["dimensions"]) == 5
616 assert data["overallScore"] == 0.0
617 for dim in data["dimensions"]:
618 assert dim["score"] == 0.0
619 assert dim["level"] == "NONE"
620 assert dim["deltaLabel"] == "unchanged"
621
622
623 async def test_proposal_merge_strategy_squash_accepted(
624 client: AsyncClient,
625 auth_headers: StrDict,
626 db_session: AsyncSession,
627 ) -> None:
628 """POST /proposals/{proposal_id}/merge accepts 'squash' as a valid mergeStrategy."""
629 repo_id = await _create_repo(client, auth_headers, "strategy-squash-repo")
630 await _push_branch_with_snapshot(db_session, repo_id, "main", {"file.py": fake_id("main")})
631 await _push_branch_with_snapshot(db_session, repo_id, "feat/squash-test", {"file.py": fake_id("feat")})
632 proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/squash-test", to_branch="main")
633 proposal_id = proposal_resp["proposalId"]
634
635 response = await client.post(
636 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
637 json={"commitHistory": "squash"},
638 headers=auth_headers,
639 )
640 assert response.status_code == 200
641 data = response.json()
642 assert data["merged"] is True
643
644
645 async def test_proposal_merge_strategy_rebase_accepted(
646 client: AsyncClient,
647 auth_headers: StrDict,
648 db_session: AsyncSession,
649 ) -> None:
650 """POST /proposals/{proposal_id}/merge accepts 'rebase' as a valid mergeStrategy."""
651 repo_id = await _create_repo(client, auth_headers, "strategy-rebase-repo")
652 await _push_branch_with_snapshot(db_session, repo_id, "main", {"file.py": fake_id("main-r")})
653 await _push_branch_with_snapshot(db_session, repo_id, "feat/rebase-test", {"file.py": fake_id("feat-r")})
654 proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/rebase-test", to_branch="main")
655 proposal_id = proposal_resp["proposalId"]
656
657 response = await client.post(
658 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
659 json={"commitHistory": "rebase"},
660 headers=auth_headers,
661 )
662 assert response.status_code == 200
663 data = response.json()
664 assert data["merged"] is True
665
666
667 # ---------------------------------------------------------------------------
668 # Proposal review comments — # ---------------------------------------------------------------------------
669
670
671 async def test_create_proposal_comment(
672 client: AsyncClient,
673 auth_headers: StrDict,
674 db_session: AsyncSession,
675 ) -> None:
676 """POST /proposals/{proposal_id}/comments creates a comment and returns threaded list."""
677 repo_id = await _create_repo(client, auth_headers, "comment-create-repo")
678 await _push_branch(db_session, repo_id, "feat/comment-test")
679 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/comment-test")
680
681 response = await client.post(
682 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/comments",
683 json={"body": "The bass line feels stiff — add swing.", "targetType": "general"},
684 headers=auth_headers,
685 )
686 assert response.status_code == 201
687 data = response.json()
688 assert "comments" in data
689 assert "total" in data
690 assert data["total"] == 1
691 comment = data["comments"][0]
692 assert comment["body"] == "The bass line feels stiff — add swing."
693 assert comment["targetType"] == "general"
694 assert "commentId" in comment
695 assert "createdAt" in comment
696
697
698 async def test_list_proposal_comments_threaded(
699 client: AsyncClient,
700 auth_headers: StrDict,
701 db_session: AsyncSession,
702 ) -> None:
703 """GET /proposals/{proposal_id}/comments returns top-level comments with nested replies."""
704 repo_id = await _create_repo(client, auth_headers, "comment-list-repo")
705 await _push_branch(db_session, repo_id, "feat/list-comments")
706 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/list-comments")
707 proposal_id = p["proposalId"]
708
709 # Create a top-level comment
710 create_resp = await client.post(
711 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
712 json={"body": "Top-level comment.", "targetType": "general"},
713 headers=auth_headers,
714 )
715 assert create_resp.status_code == 201
716 parent_id = create_resp.json()["comments"][0]["commentId"]
717
718 # Reply to it
719 reply_resp = await client.post(
720 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
721 json={"body": "A reply.", "targetType": "general", "parentCommentId": parent_id},
722 headers=auth_headers,
723 )
724 assert reply_resp.status_code == 201
725
726 # Fetch threaded list
727 list_resp = await client.get(
728 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
729 headers=auth_headers,
730 )
731 assert list_resp.status_code == 200
732 data = list_resp.json()
733 assert data["total"] == 2
734 # Only one top-level comment
735 assert len(data["comments"]) == 1
736 top = data["comments"][0]
737 assert len(top["replies"]) == 1
738 assert top["replies"][0]["body"] == "A reply."
739
740
741 async def test_comment_targets_track(
742 client: AsyncClient,
743 auth_headers: StrDict,
744 db_session: AsyncSession,
745 ) -> None:
746 """POST /comments with target_type=region stores track and beat range correctly."""
747 repo_id = await _create_repo(client, auth_headers, "comment-track-repo")
748 await _push_branch(db_session, repo_id, "feat/track-comment")
749 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/track-comment")
750
751 response = await client.post(
752 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/comments",
753 json={
754 "body": "Beats 16-24 on bass feel rushed.",
755 "targetType": "region",
756 "targetTrack": "bass",
757 "targetBeatStart": 16.0,
758 "targetBeatEnd": 24.0,
759 },
760 headers=auth_headers,
761 )
762 assert response.status_code == 201
763 comment = response.json()["comments"][0]
764 assert comment["targetType"] == "region"
765 assert comment["targetTrack"] == "bass"
766 assert comment["targetBeatStart"] == 16.0
767 assert comment["targetBeatEnd"] == 24.0
768
769
770 async def test_comment_requires_auth(client: AsyncClient) -> None:
771 """POST /proposals/{proposal_id}/comments returns 401 without a MSign Authorization header."""
772 response = await client.post(
773 "/api/repos/r/proposals/p/comments",
774 json={"body": "Unauthorized attempt."},
775 )
776 assert response.status_code == 401
777
778
779 async def test_reply_to_comment(
780 client: AsyncClient,
781 auth_headers: StrDict,
782 db_session: AsyncSession,
783 ) -> None:
784 """Replying to a comment creates a threaded child visible in the list."""
785 repo_id = await _create_repo(client, auth_headers, "comment-reply-repo")
786 await _push_branch(db_session, repo_id, "feat/reply-test")
787 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/reply-test")
788 proposal_id = p["proposalId"]
789
790 parent_resp = await client.post(
791 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
792 json={"body": "Original comment.", "targetType": "general"},
793 headers=auth_headers,
794 )
795 parent_id = parent_resp.json()["comments"][0]["commentId"]
796
797 reply_resp = await client.post(
798 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
799 json={"body": "Reply here.", "targetType": "general", "parentCommentId": parent_id},
800 headers=auth_headers,
801 )
802 assert reply_resp.status_code == 201
803 data = reply_resp.json()
804 # Still only one top-level comment; total is 2
805 assert data["total"] == 2
806 assert len(data["comments"]) == 1
807 reply = data["comments"][0]["replies"][0]
808 assert reply["body"] == "Reply here."
809 assert reply["parentCommentId"] == parent_id
810
811
812 # ---------------------------------------------------------------------------
813 # Issue #384 — affected_sections and divergence service helpers
814 # ---------------------------------------------------------------------------
815
816
817 def test_extract_affected_sections_returns_empty_when_no_keywords() -> None:
818 """affected_sections is empty when no commit mentions a section keyword."""
819 from musehub.services.musehub_divergence import extract_affected_sections
820
821 messages: tuple[str, ...] = (
822 "add jazzy chord voicing",
823 "fix drum quantization",
824 "update harmonic progression",
825 )
826 assert extract_affected_sections(messages) == []
827
828
829 def test_extract_affected_sections_returns_only_mentioned_keywords() -> None:
830 """affected_sections lists only the sections actually named in commits."""
831 from musehub.services.musehub_divergence import extract_affected_sections
832
833 messages: tuple[str, ...] = (
834 "rework the chorus melody",
835 "add a new bridge transition",
836 "fix drum quantization",
837 )
838 result = extract_affected_sections(messages)
839 assert "Chorus" in result
840 assert "Bridge" in result
841 assert "Verse" not in result
842 assert "Intro" not in result
843 assert "Outro" not in result
844
845
846 def test_extract_affected_sections_case_insensitive() -> None:
847 """Keyword matching is case-insensitive."""
848 from musehub.services.musehub_divergence import extract_affected_sections
849
850 messages: tuple[str, ...] = ("rewrite VERSE chord progression",)
851 result = extract_affected_sections(messages)
852 assert result == ["Verse"]
853
854
855 def test_extract_affected_sections_deduplicates() -> None:
856 """The same keyword appearing in multiple commits is only returned once."""
857 from musehub.services.musehub_divergence import extract_affected_sections
858
859 messages: tuple[str, ...] = (
860 "update chorus dynamics",
861 "fix chorus timing",
862 "tweak chorus reverb",
863 )
864 result = extract_affected_sections(messages)
865 assert result.count("Chorus") == 1
866
867
868 def test_build_zero_diff_response_structure() -> None:
869 """build_zero_diff_response returns five dimensions all at score 0.0."""
870 from musehub.services.musehub_divergence import ALL_DIMENSIONS, build_zero_diff_response
871
872 resp = build_zero_diff_response(
873 proposal_id="proposal-abc",
874 repo_id="repo-xyz",
875 from_branch="feat/test",
876 to_branch="main",
877 )
878 assert resp.proposal_id == "proposal-abc"
879 assert resp.repo_id == "repo-xyz"
880 assert resp.from_branch == "feat/test"
881 assert resp.to_branch == "main"
882 assert resp.overall_score == 0.0
883 assert resp.common_ancestor is None
884 assert resp.affected_sections == []
885 assert len(resp.dimensions) == len(ALL_DIMENSIONS)
886 for dim in resp.dimensions:
887 assert dim.score == 0.0
888 assert dim.level == "NONE"
889 assert dim.delta_label == "unchanged"
890
891
892 def test_build_proposal_diff_response_affected_sections_uses_commit_messages() -> None:
893 """build_proposal_diff_response derives affected_sections from commit messages, not score heuristic."""
894 from musehub.services.musehub_divergence import (
895 MuseHubDimensionDivergence,
896 MuseHubDivergenceLevel,
897 MuseHubDivergenceResult,
898 build_proposal_diff_response,
899 )
900
901 # Structural score > 0, but NO section keyword in any commit message.
902 structural_dim = MuseHubDimensionDivergence(
903 dimension="structural",
904 level=MuseHubDivergenceLevel.LOW,
905 score=0.3,
906 description="Minor structural divergence.",
907 branch_a_commits=1,
908 branch_b_commits=0,
909 )
910 result = MuseHubDivergenceResult(
911 repo_id="repo-1",
912 branch_a="main",
913 branch_b="feat/changes",
914 common_ancestor="abc123",
915 dimensions=(structural_dim,),
916 overall_score=0.3,
917 all_messages=("refactor arrangement flow", "update drum pattern"),
918 )
919 resp = build_proposal_diff_response(
920 proposal_id="proposal-1",
921 from_branch="feat/changes",
922 to_branch="main",
923 result=result,
924 )
925 # No section keyword in commit messages → empty list, even though structural score > 0
926 assert resp.affected_sections == []
927
928
929 # ---------------------------------------------------------------------------
930 # Proposal reviewer assignment endpoints — # ---------------------------------------------------------------------------
931
932
933 async def test_request_reviewers_creates_pending_rows(
934 client: AsyncClient,
935 auth_headers: StrDict,
936 db_session: AsyncSession,
937 ) -> None:
938 """POST /reviewers creates pending review rows for each requested username."""
939 repo_id = await _create_repo(client, auth_headers, "reviewer-create-repo")
940 await _push_branch(db_session, repo_id, "feat/reviewer-test")
941 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/reviewer-test")
942 proposal_id = p["proposalId"]
943
944 response = await client.post(
945 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
946 json={"reviewers": ["alice", "bob"]},
947 headers=auth_headers,
948 )
949 assert response.status_code == 201
950 data = response.json()
951 assert "reviews" in data
952 assert data["total"] == 2
953 usernames = {r["reviewerUsername"] for r in data["reviews"]}
954 assert usernames == {"alice", "bob"}
955 for review in data["reviews"]:
956 assert review["state"] == "pending"
957 assert review["submittedAt"] is None
958
959
960 async def test_request_reviewers_idempotent(
961 client: AsyncClient,
962 auth_headers: StrDict,
963 db_session: AsyncSession,
964 ) -> None:
965 """Re-requesting the same reviewer does not create a duplicate row."""
966 repo_id = await _create_repo(client, auth_headers, "reviewer-idempotent-repo")
967 await _push_branch(db_session, repo_id, "feat/idempotent")
968 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/idempotent")
969 proposal_id = p["proposalId"]
970
971 await client.post(
972 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
973 json={"reviewers": ["alice"]},
974 headers=auth_headers,
975 )
976 # Second request for the same reviewer
977 response = await client.post(
978 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
979 json={"reviewers": ["alice"]},
980 headers=auth_headers,
981 )
982 assert response.status_code == 201
983 assert response.json()["total"] == 1 # still only one row
984
985
986 async def test_request_reviewers_requires_auth(client: AsyncClient) -> None:
987 """POST /reviewers returns 401 without a MSign Authorization header."""
988 response = await client.post(
989 "/api/repos/r/proposals/p/reviewers",
990 json={"reviewers": ["alice"]},
991 )
992 assert response.status_code == 401
993
994
995 async def test_remove_reviewer_deletes_pending_row(
996 client: AsyncClient,
997 auth_headers: StrDict,
998 db_session: AsyncSession,
999 ) -> None:
1000 """DELETE /reviewers/{username} removes a pending reviewer assignment."""
1001 repo_id = await _create_repo(client, auth_headers, "reviewer-delete-repo")
1002 await _push_branch(db_session, repo_id, "feat/remove-reviewer")
1003 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/remove-reviewer")
1004 proposal_id = p["proposalId"]
1005
1006 await client.post(
1007 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
1008 json={"reviewers": ["alice", "bob"]},
1009 headers=auth_headers,
1010 )
1011
1012 response = await client.delete(
1013 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/alice",
1014 headers=auth_headers,
1015 )
1016 assert response.status_code == 200
1017 data = response.json()
1018 assert data["total"] == 1
1019 assert data["reviews"][0]["reviewerUsername"] == "bob"
1020
1021
1022 async def test_remove_reviewer_not_found_returns_404(
1023 client: AsyncClient,
1024 auth_headers: StrDict,
1025 db_session: AsyncSession,
1026 ) -> None:
1027 """DELETE /reviewers/{username} returns 404 when the reviewer was never requested."""
1028 repo_id = await _create_repo(client, auth_headers, "reviewer-404-repo")
1029 await _push_branch(db_session, repo_id, "feat/remove-404")
1030 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/remove-404")
1031 proposal_id = p["proposalId"]
1032
1033 response = await client.delete(
1034 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/nobody",
1035 headers=auth_headers,
1036 )
1037 assert response.status_code == 404
1038
1039
1040 # ---------------------------------------------------------------------------
1041 # Proposal review submission endpoints — # ---------------------------------------------------------------------------
1042
1043
1044 async def test_list_reviews_empty_for_new_proposal(
1045 client: AsyncClient,
1046 auth_headers: StrDict,
1047 db_session: AsyncSession,
1048 ) -> None:
1049 """GET /reviews returns an empty list for a proposal with no reviews assigned."""
1050 repo_id = await _create_repo(client, auth_headers, "reviews-empty-repo")
1051 await _push_branch(db_session, repo_id, "feat/list-reviews-empty")
1052 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/list-reviews-empty")
1053 proposal_id = p["proposalId"]
1054
1055 response = await client.get(
1056 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1057 headers=auth_headers,
1058 )
1059 assert response.status_code == 200
1060 data = response.json()
1061 assert data["total"] == 0
1062 assert data["reviews"] == []
1063
1064
1065 async def test_list_reviews_filter_by_state(
1066 client: AsyncClient,
1067 auth_headers: StrDict,
1068 db_session: AsyncSession,
1069 ) -> None:
1070 """GET /reviews?state=pending returns only pending reviews."""
1071 repo_id = await _create_repo(client, auth_headers, "reviews-filter-repo")
1072 await _push_branch(db_session, repo_id, "feat/filter-state")
1073 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/filter-state")
1074 proposal_id = p["proposalId"]
1075
1076 await client.post(
1077 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
1078 json={"reviewers": ["alice", "bob"]},
1079 headers=auth_headers,
1080 )
1081
1082 response = await client.get(
1083 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews?state=pending",
1084 headers=auth_headers,
1085 )
1086 assert response.status_code == 200
1087 data = response.json()
1088 assert data["total"] == 2
1089 for r in data["reviews"]:
1090 assert r["state"] == "pending"
1091
1092
1093 async def test_submit_review_approve(
1094 client: AsyncClient,
1095 auth_headers: StrDict,
1096 db_session: AsyncSession,
1097 ) -> None:
1098 """POST /reviews with event=approve sets state to approved and records submitted_at."""
1099 repo_id = await _create_repo(client, auth_headers, "review-approve-repo")
1100 await _push_branch(db_session, repo_id, "feat/approve-test")
1101 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/approve-test")
1102 proposal_id = p["proposalId"]
1103
1104 response = await client.post(
1105 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1106 json={"verdict": "approve", "body": "Sounds great — the harmonic transitions are perfect."},
1107 headers=auth_headers,
1108 )
1109 assert response.status_code == 201
1110 data = response.json()
1111 assert data["state"] == "approved"
1112 assert data["submittedAt"] is not None
1113 assert "Sounds great" in (data["body"] or "")
1114
1115
1116 async def test_submit_review_request_changes(
1117 client: AsyncClient,
1118 auth_headers: StrDict,
1119 db_session: AsyncSession,
1120 ) -> None:
1121 """POST /reviews with event=request_changes sets state to changes_requested."""
1122 repo_id = await _create_repo(client, auth_headers, "review-changes-repo")
1123 await _push_branch(db_session, repo_id, "feat/changes-test")
1124 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/changes-test")
1125 proposal_id = p["proposalId"]
1126
1127 response = await client.post(
1128 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1129 json={"verdict": "request_changes", "body": "The bridge needs more harmonic tension."},
1130 headers=auth_headers,
1131 )
1132 assert response.status_code == 201
1133 data = response.json()
1134 assert data["state"] == "changes_requested"
1135 assert data["submittedAt"] is not None
1136
1137
1138 async def test_submit_review_updates_existing_row(
1139 client: AsyncClient,
1140 auth_headers: StrDict,
1141 db_session: AsyncSession,
1142 ) -> None:
1143 """Submitting a second review replaces the existing row state in-place."""
1144 repo_id = await _create_repo(client, auth_headers, "review-update-repo")
1145 await _push_branch(db_session, repo_id, "feat/update-review")
1146 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/update-review")
1147 proposal_id = p["proposalId"]
1148
1149 # First: request changes
1150 await client.post(
1151 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1152 json={"verdict": "request_changes", "body": "Not happy with the bridge."},
1153 headers=auth_headers,
1154 )
1155
1156 # After author fixes, reviewer now approves
1157 response = await client.post(
1158 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1159 json={"verdict": "approve", "body": "Looks good now!"},
1160 headers=auth_headers,
1161 )
1162 assert response.status_code == 201
1163 data = response.json()
1164 assert data["state"] == "approved"
1165
1166 # Only one review row should exist
1167 list_resp = await client.get(
1168 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1169 headers=auth_headers,
1170 )
1171 assert list_resp.json()["total"] == 1
1172
1173
1174 async def test_remove_reviewer_after_submit_returns_409(
1175 client: AsyncClient,
1176 auth_headers: StrDict,
1177 db_session: AsyncSession,
1178 ) -> None:
1179 """DELETE /reviewers/{username} returns 409 when reviewer already submitted a review.
1180
1181 The test context handle is 'testuser'. Submitting a review via POST /reviews
1182 creates a row with that handle as reviewer_username, and state=approved.
1183 Attempting to DELETE that reviewer must return 409 because the row is no
1184 longer pending.
1185 """
1186 reviewer_handle = "testuser"
1187
1188 repo_id = await _create_repo(client, auth_headers, "reviewer-submitted-repo")
1189 await _push_branch(db_session, repo_id, "feat/submitted-review")
1190 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/submitted-review")
1191 proposal_id = p["proposalId"]
1192
1193 # Submit a review — this creates an "approved" row for the test context handle
1194 submit_resp = await client.post(
1195 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1196 json={"verdict": "approve", "body": "Approved"},
1197 headers=auth_headers,
1198 )
1199 assert submit_resp.status_code == 201
1200
1201 # Attempting to remove the reviewer whose row is already approved must return 409
1202 response = await client.delete(
1203 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/{reviewer_handle}",
1204 headers=auth_headers,
1205 )
1206 assert response.status_code == 409
1207
1208
1209 async def test_submit_review_invalid_event_returns_422(
1210 client: AsyncClient,
1211 auth_headers: StrDict,
1212 db_session: AsyncSession,
1213 ) -> None:
1214 """POST /reviews with an invalid event value returns 422 Unprocessable Entity."""
1215 repo_id = await _create_repo(client, auth_headers, "review-invalid-event-repo")
1216 await _push_branch(db_session, repo_id, "feat/invalid-event")
1217 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/invalid-event")
1218 proposal_id = p["proposalId"]
1219
1220 response = await client.post(
1221 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1222 json={"event": "INVALID", "body": ""},
1223 headers=auth_headers,
1224 )
1225 assert response.status_code == 422
1226
1227
1228 async def test_submit_review_forbidden_on_private_repo_for_non_owner(
1229 client: AsyncClient,
1230 auth_headers: StrDict,
1231 db_session: AsyncSession,
1232 ) -> None:
1233 """POST /reviews on a private repo owned by another user returns 403."""
1234 from musehub.db.musehub_repo_models import MusehubRepo
1235
1236 _ot = datetime.now(tz=timezone.utc)
1237 _oid = compute_identity_id(b"other-owner")
1238 other_repo = MusehubRepo(
1239 repo_id=compute_repo_id(_oid, "private-review-repo", "code", _ot.isoformat()),
1240 name="private-review-repo",
1241 owner="other-owner",
1242 slug="private-review-repo",
1243 visibility="private",
1244 owner_user_id=_oid,
1245 created_at=_ot,
1246 updated_at=_ot,
1247 )
1248 db_session.add(other_repo)
1249 await db_session.commit()
1250
1251 response = await client.post(
1252 f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/reviews",
1253 json={"verdict": "approve", "body": ""},
1254 headers=auth_headers,
1255 )
1256 assert response.status_code == 403
1257
1258
1259 async def test_create_proposal_comment_forbidden_for_non_owner(
1260 client: AsyncClient,
1261 auth_headers: StrDict,
1262 db_session: AsyncSession,
1263 ) -> None:
1264 """POST /proposals/{proposal_id}/comments on a private repo owned by another user returns 403."""
1265 from musehub.db.musehub_repo_models import MusehubRepo
1266
1267 _ot = datetime.now(tz=timezone.utc)
1268 _oid = compute_identity_id(b"other-owner")
1269 other_repo = MusehubRepo(
1270 repo_id=compute_repo_id(_oid, "private-comment-repo", "code", _ot.isoformat()),
1271 name="private-comment-repo",
1272 owner="other-owner",
1273 slug="private-comment-repo",
1274 visibility="private",
1275 owner_user_id=_oid,
1276 created_at=_ot,
1277 updated_at=_ot,
1278 )
1279 db_session.add(other_repo)
1280 await db_session.commit()
1281
1282 response = await client.post(
1283 f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/comments",
1284 json={"body": "test comment", "targetType": "general"},
1285 headers=auth_headers,
1286 )
1287 assert response.status_code == 403
1288
1289
1290 def test_build_proposal_diff_response_affected_sections_non_empty_when_keywords_present() -> None:
1291 """build_proposal_diff_response populates affected_sections from commit message keywords."""
1292 from musehub.services.musehub_divergence import (
1293 MuseHubDimensionDivergence,
1294 MuseHubDivergenceLevel,
1295 MuseHubDivergenceResult,
1296 build_proposal_diff_response,
1297 )
1298
1299 structural_dim = MuseHubDimensionDivergence(
1300 dimension="structural",
1301 level=MuseHubDivergenceLevel.LOW,
1302 score=0.3,
1303 description="Minor structural divergence.",
1304 branch_a_commits=2,
1305 branch_b_commits=1,
1306 )
1307 result = MuseHubDivergenceResult(
1308 repo_id="repo-2",
1309 branch_a="main",
1310 branch_b="feat/rewrite",
1311 common_ancestor="def456",
1312 dimensions=(structural_dim,),
1313 overall_score=0.3,
1314 all_messages=("add new verse section", "polish intro melody"),
1315 )
1316 resp = build_proposal_diff_response(
1317 proposal_id="proposal-2",
1318 from_branch="feat/rewrite",
1319 to_branch="main",
1320 result=result,
1321 )
1322 assert "Verse" in resp.affected_sections
1323 assert "Intro" in resp.affected_sections
1324 assert "Chorus" not in resp.affected_sections
1325
1326
1327 # ---------------------------------------------------------------------------
1328 # Regression — server-side proposal merge snapshot correctness
1329 # ---------------------------------------------------------------------------
1330
1331
1332 async def _push_branch_with_snapshot(
1333 db: AsyncSession,
1334 repo_id: str,
1335 branch_name: str,
1336 manifest: StrDict,
1337 message: str = "commit",
1338 parent_ids: list[str] | None = None,
1339 ) -> tuple[str, str]:
1340 """Insert a branch with one commit and a real snapshot; return (commit_id, snapshot_id)."""
1341 snapshot_id = compute_snapshot_id(manifest)
1342 now = datetime.now(tz=timezone.utc)
1343 commit_id = compute_commit_id(parent_ids or [], snapshot_id, message, now.isoformat())
1344
1345 snap = MusehubSnapshot(
1346 snapshot_id=snapshot_id,
1347 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
1348 entry_count=len(manifest),
1349 )
1350 commit = MusehubCommit(
1351 commit_id=commit_id,
1352 branch=branch_name,
1353 parent_ids=parent_ids or [],
1354 message=message,
1355 author="testuser",
1356 timestamp=now,
1357 snapshot_id=snapshot_id,
1358 )
1359 branch = MusehubBranch(
1360 branch_id=compute_branch_id(repo_id, branch_name),
1361 repo_id=repo_id,
1362 name=branch_name,
1363 head_commit_id=commit_id,
1364 )
1365 db.add(snap)
1366 db.add(commit)
1367 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
1368 db.add(branch)
1369 await db.commit()
1370 return commit_id, snapshot_id
1371
1372
1373 async def test_merge_proposal_snapshot_includes_to_branch_only_files(
1374 client: AsyncClient,
1375 auth_headers: StrDict,
1376 db_session: AsyncSession,
1377 ) -> None:
1378 """Regression: merge commit snapshot must contain to_branch-only files.
1379
1380 Bug: merge_proposal used from_head_snapshot_id verbatim as the merge commit's
1381 snapshot. When to_branch (main) had files that from_branch never touched
1382 (e.g. executor.py added after the proposal branch was cut), those files were
1383 absent from the merge commit's snapshot. A subsequent checkout would then
1384 delete executor.py from the working tree, reproducing the MuseHub incident.
1385
1386 Expected: merge commit snapshot = from_branch manifest ∪ to_branch-only files.
1387 """
1388 from sqlalchemy import select
1389 from musehub.db.musehub_repo_models import MusehubCommit as DbCommit
1390 from musehub.db.musehub_repo_models import MusehubSnapshot as DbSnapshot
1391
1392 repo_id = await _create_repo(client, auth_headers, "snapshot-correctness-repo")
1393
1394 # to_branch (main) has: database.py v1 + executor.py (added after branch diverged).
1395 to_commit, to_snap_id = await _push_branch_with_snapshot(
1396 db_session, repo_id, "main",
1397 manifest={"database.py": fake_id("db-v1"), "executor.py": fake_id("executor-fixed")},
1398 message="main: add executor.py",
1399 )
1400
1401 # from_branch (feat) has: database.py v2 + new_feature.py (executor.py absent).
1402 from_commit, from_snap_id = await _push_branch_with_snapshot(
1403 db_session, repo_id, "feat/add-feature",
1404 manifest={"database.py": fake_id("db-v2"), "new_feature.py": fake_id("new-feature")},
1405 message="feat: database v2 + new_feature.py",
1406 )
1407
1408 p = await _create_proposal_helper(
1409 client, auth_headers, repo_id,
1410 title="Add new feature",
1411 from_branch="feat/add-feature",
1412 to_branch="main",
1413 )
1414
1415 merge_resp = await client.post(
1416 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
1417 json={"mergeStrategy": "merge_commit"},
1418 headers=auth_headers,
1419 )
1420 assert merge_resp.status_code == 200, merge_resp.text
1421 merge_commit_id: str = str(merge_resp.json()["mergeCommitId"])
1422
1423 # Load the merge commit's snapshot from the DB.
1424 result = await db_session.execute(
1425 select(DbCommit).where(DbCommit.commit_id == merge_commit_id)
1426 )
1427 merge_commit = result.scalar_one_or_none()
1428 assert merge_commit is not None, "merge commit must be stored in DB"
1429 assert merge_commit.snapshot_id is not None, "merge commit must have a snapshot"
1430
1431 snap_result = await db_session.execute(
1432 select(DbSnapshot).where(DbSnapshot.snapshot_id == merge_commit.snapshot_id)
1433 )
1434 snap = snap_result.scalar_one_or_none()
1435 assert snap is not None, f"snapshot {merge_commit.snapshot_id[:8]} must exist in DB"
1436
1437 from musehub.services.musehub_snapshot import get_snapshot_manifest
1438 manifest = await get_snapshot_manifest(db_session, merge_commit.snapshot_id)
1439
1440 # from_branch-only: new_feature.py must be present.
1441 assert "new_feature.py" in manifest, (
1442 "REGRESSION: new_feature.py (from_branch-only addition) absent from merge commit snapshot."
1443 )
1444
1445 # to_branch-only: executor.py must be present.
1446 assert "executor.py" in manifest, (
1447 "REGRESSION: executor.py (to_branch-only file) absent from merge commit snapshot.\n"
1448 "The server merge_proposal used from_branch snapshot verbatim and discarded\n"
1449 "all to_branch-only changes — identical data loss to the strategy=ours bug."
1450 )
1451
1452
1453 async def test_merge_proposal_snapshot_is_not_from_branch_verbatim(
1454 client: AsyncClient,
1455 auth_headers: StrDict,
1456 db_session: AsyncSession,
1457 ) -> None:
1458 """Regression: merge commit snapshot must NOT equal from_branch snapshot verbatim.
1459
1460 If they're equal, it means to_branch-only changes were silently discarded.
1461 """
1462 from sqlalchemy import select
1463 from musehub.db.musehub_repo_models import MusehubCommit as DbCommit
1464
1465 repo_id = await _create_repo(client, auth_headers, "snapshot-not-verbatim-repo")
1466
1467 await _push_branch_with_snapshot(
1468 db_session, repo_id, "main",
1469 manifest={"shared.py": fake_id("shared"), "to-only.py": fake_id("to-only-content")},
1470 )
1471 _, from_snap_id = await _push_branch_with_snapshot(
1472 db_session, repo_id, "feat",
1473 manifest={"shared.py": fake_id("shared"), "from-only.py": fake_id("from-only-content")},
1474 )
1475
1476 p = await _create_proposal_helper(
1477 client, auth_headers, repo_id,
1478 title="Merge feat",
1479 from_branch="feat",
1480 to_branch="main",
1481 )
1482 resp = await client.post(
1483 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
1484 json={"mergeStrategy": "merge_commit"},
1485 headers=auth_headers,
1486 )
1487 assert resp.status_code == 200
1488 merge_commit_id = str(resp.json()["mergeCommitId"])
1489
1490 result = await db_session.execute(
1491 select(DbCommit).where(DbCommit.commit_id == merge_commit_id)
1492 )
1493 merge_commit = result.scalar_one_or_none()
1494 assert merge_commit is not None
1495
1496 assert merge_commit.snapshot_id != from_snap_id, (
1497 "REGRESSION: merge commit snapshot equals from_branch snapshot verbatim.\n"
1498 "to_branch-only file 'to-only.py' was silently discarded."
1499 )
1500
1501
1502 async def test_merge_proposal_snapshot_id_uses_correct_formula(
1503 client: AsyncClient,
1504 auth_headers: StrDict,
1505 db_session: AsyncSession,
1506 ) -> None:
1507 """Contract: the merge commit snapshot ID must equal compute_snapshot_id(merged_manifest).
1508
1509 This test locks down the hash formula used by the server-side merge_proposal path.
1510 If the server switches back to json.dumps or any other scheme, this test
1511 catches it immediately — before corrupt IDs reach production history.
1512 """
1513 from sqlalchemy import select
1514 from musehub.db.musehub_repo_models import MusehubCommit as DbCommit
1515 from musehub.db.musehub_repo_models import MusehubSnapshot as DbSnapshot
1516
1517 repo_id = await _create_repo(client, auth_headers, "snapshot-formula-contract-repo")
1518
1519 to_manifest = {
1520 "agentception/app.py": fake_id("aaa111"),
1521 "pyproject.toml": fake_id("bbb222"),
1522 }
1523 from_manifest = {
1524 "agentception/app.py": fake_id("ccc333"), # overrides to_branch version
1525 "agentception/new_module.py": fake_id("ddd444"),
1526 }
1527 # Expected merged manifest: from_branch values take precedence; to_branch-only
1528 # files are preserved.
1529 expected_merged = {**to_manifest, **from_manifest}
1530 expected_snapshot_id = compute_snapshot_id(expected_merged)
1531
1532 await _push_branch_with_snapshot(db_session, repo_id, "main", manifest=to_manifest)
1533 await _push_branch_with_snapshot(db_session, repo_id, "feat/formula-check", manifest=from_manifest)
1534
1535 p = await _create_proposal_helper(
1536 client, auth_headers, repo_id,
1537 title="Formula check proposal",
1538 from_branch="feat/formula-check",
1539 to_branch="main",
1540 )
1541 merge_resp = await client.post(
1542 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
1543 json={"mergeStrategy": "merge_commit"},
1544 headers=auth_headers,
1545 )
1546 assert merge_resp.status_code == 200, merge_resp.text
1547 merge_commit_id = str(merge_resp.json()["mergeCommitId"])
1548
1549 commit_result = await db_session.execute(
1550 select(DbCommit).where(DbCommit.commit_id == merge_commit_id)
1551 )
1552 merge_commit = commit_result.scalar_one_or_none()
1553 assert merge_commit is not None
1554
1555 snap_result = await db_session.execute(
1556 select(DbSnapshot).where(DbSnapshot.snapshot_id == merge_commit.snapshot_id)
1557 )
1558 snap = snap_result.scalar_one_or_none()
1559 assert snap is not None
1560
1561 assert snap.snapshot_id == expected_snapshot_id, (
1562 f"Merge commit snapshot ID does not match compute_snapshot_id(merged_manifest).\n"
1563 f" server produced: {snap.snapshot_id}\n"
1564 f" formula expected: {expected_snapshot_id}\n"
1565 "The server is using a different hash formula than the muse client library — "
1566 "every proposal merge will produce corrupt snapshots that fail content-hash verification."
1567 )
1568
1569
1570 # ---------------------------------------------------------------------------
1571 # POST /repos/{repo_id}/proposals/{proposal_id}/close
1572 # ---------------------------------------------------------------------------
1573
1574
1575 async def test_close_proposal_sets_state_closed(
1576 client: AsyncClient,
1577 auth_headers: StrDict,
1578 db_session: AsyncSession,
1579 ) -> None:
1580 """POST .../close on an open proposal must set state to 'closed'."""
1581 repo_id = await _create_repo(client, auth_headers, "close-proposal-open-repo")
1582 await _push_branch(db_session, repo_id, "feat/close-me")
1583 proposal = await _create_proposal_helper(
1584 client, auth_headers, repo_id,
1585 title="Close me",
1586 from_branch="feat/close-me",
1587 to_branch="main",
1588 )
1589 r = await client.post(
1590 f"/api/repos/{repo_id}/proposals/{proposal['proposalId']}/close",
1591 headers=auth_headers,
1592 )
1593 assert r.status_code == 200, r.text
1594 assert r.json()["state"] == "closed"
1595
1596
1597 async def test_close_proposal_already_closed_returns_409(
1598 client: AsyncClient,
1599 auth_headers: StrDict,
1600 db_session: AsyncSession,
1601 ) -> None:
1602 """POST .../close on an already-closed proposal must return 409."""
1603 repo_id = await _create_repo(client, auth_headers, "close-proposal-409-repo")
1604 await _push_branch(db_session, repo_id, "feat/close-twice")
1605 proposal = await _create_proposal_helper(
1606 client, auth_headers, repo_id,
1607 title="Close twice",
1608 from_branch="feat/close-twice",
1609 to_branch="main",
1610 )
1611 pid = proposal["proposalId"]
1612 await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers)
1613 r = await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers)
1614 assert r.status_code == 409, r.text
1615
1616
1617 async def test_close_unknown_proposal_returns_404(
1618 client: AsyncClient,
1619 auth_headers: StrDict,
1620 db_session: AsyncSession,
1621 ) -> None:
1622 """POST .../close on a nonexistent proposal_id must return 404."""
1623 repo_id = await _create_repo(client, auth_headers, "close-proposal-404-repo")
1624 r = await client.post(
1625 f"/api/repos/{repo_id}/proposals/sha256:{'dead' * 16}/close",
1626 headers=auth_headers,
1627 )
1628 assert r.status_code == 404, r.text
1629
1630
1631 async def test_close_proposal_requires_auth(client: AsyncClient) -> None:
1632 """POST .../close without auth must return 401 or 403."""
1633 r = await client.post("/api/repos/any-repo/proposals/any-id/close")
1634 assert r.status_code in (401, 403), r.text
1635
1636
1637 async def test_closed_proposal_appears_in_closed_list(
1638 client: AsyncClient,
1639 auth_headers: StrDict,
1640 db_session: AsyncSession,
1641 ) -> None:
1642 """After closing, proposal must appear in GET proposals?state=closed."""
1643 repo_id = await _create_repo(client, auth_headers, "close-proposal-list-repo")
1644 await _push_branch(db_session, repo_id, "feat/list-closed")
1645 proposal = await _create_proposal_helper(
1646 client, auth_headers, repo_id,
1647 title="List closed",
1648 from_branch="feat/list-closed",
1649 to_branch="main",
1650 )
1651 pid = proposal["proposalId"]
1652 await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers)
1653 r = await client.get(f"/api/repos/{repo_id}/proposals?state=closed", headers=auth_headers)
1654 assert r.status_code == 200, r.text
1655 ids = [p["proposalId"] for p in r.json()["proposals"]]
1656 assert pid in ids
1657
1658
1659 async def test_submit_review_accepts_verdict_field(
1660 client: AsyncClient,
1661 auth_headers: StrDict,
1662 db_session: AsyncSession,
1663 ) -> None:
1664 """POST /reviews with verdict= (CLI term) must work identically to event=.
1665
1666 The CLI sends 'verdict' because that's what reviewers think about.
1667 The server must accept both 'verdict' and 'event' (backward compat).
1668 """
1669 repo_id = await _create_repo(client, auth_headers, "review-verdict-repo")
1670 await _push_branch(db_session, repo_id, "feat/verdict-test")
1671 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/verdict-test")
1672 proposal_id = p["proposalId"]
1673
1674 response = await client.post(
1675 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1676 json={"verdict": "approve", "body": "LGTM"},
1677 headers=auth_headers,
1678 )
1679 assert response.status_code == 201, (
1680 f"verdict=approve must be accepted (CLI uses --verdict). "
1681 f"Got {response.status_code}: {response.text}"
1682 )
1683 assert response.json()["state"] == "approved"
1684
1685
1686 async def test_submit_review_event_field_rejected(
1687 client: AsyncClient,
1688 auth_headers: StrDict,
1689 db_session: AsyncSession,
1690 ) -> None:
1691 """POST /reviews with only event= (old field name) returns 422 — use verdict."""
1692 repo_id = await _create_repo(client, auth_headers, "review-event-reject-repo")
1693 await _push_branch(db_session, repo_id, "feat/event-reject")
1694 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/event-reject")
1695 proposal_id = p["proposalId"]
1696
1697 response = await client.post(
1698 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1699 json={"event": "approve", "body": "old field name"},
1700 headers=auth_headers,
1701 )
1702 assert response.status_code == 422, (
1703 "Sending 'event' without 'verdict' must fail — the field is 'verdict' now"
1704 )
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 10 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago