gabriel / musehub public
test_musehub_prs.py python
1256 lines 43.4 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for MuseHub pull request endpoints.
2
3 Covers every acceptance criterion from issues #41, #215:
4 - POST /repos/{repo_id}/pull-requests creates PR in open state
5 - 422 when from_branch == to_branch
6 - 404 when from_branch does not exist
7 - GET /pull-requests returns all PRs (open + merged + closed)
8 - GET /pull-requests/{pr_id} returns full PR detail; 404 if not found
9 - GET /pull-requests/{pr_id}/diff returns five-dimension musical diff scores
10 - GET /pull-requests/{pr_id}/diff graceful degradation when branches have no commits
11 - POST /pull-requests/{pr_id}/merge creates merge commit, sets state merged
12 - POST /pull-requests/{pr_id}/merge accepts squash and rebase strategies
13 - 409 when merging an already-merged PR
14 - All endpoints require valid JWT
15 - affected_sections derived from commit message text, not structural score heuristic
16 - build_pr_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 import uuid
24 from datetime import datetime, timezone
25
26 import pytest
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from musehub.db.musehub_models import MusehubBranch, MusehubCommit
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37
38 async def _create_repo(
39 client: AsyncClient,
40 auth_headers: dict[str, str],
41 name: str = "neo-soul-repo",
42 ) -> str:
43 """Create a repo via the API and return its repo_id."""
44 response = await client.post(
45 "/api/v1/repos",
46 json={"name": name, "owner": "testuser", "initialize": False},
47 headers=auth_headers,
48 )
49 assert response.status_code == 201
50 return str(response.json()["repoId"])
51
52
53 async def _push_branch(
54 db: AsyncSession,
55 repo_id: str,
56 branch_name: str,
57 ) -> str:
58 """Insert a branch with one commit so the branch exists and has a head commit.
59
60 Returns the commit_id so callers can reference it if needed.
61 """
62 commit_id = uuid.uuid4().hex
63 commit = MusehubCommit(
64 commit_id=commit_id,
65 repo_id=repo_id,
66 branch=branch_name,
67 parent_ids=[],
68 message=f"Initial commit on {branch_name}",
69 author="rene",
70 timestamp=datetime.now(tz=timezone.utc),
71 )
72 branch = MusehubBranch(
73 repo_id=repo_id,
74 name=branch_name,
75 head_commit_id=commit_id,
76 )
77 db.add(commit)
78 db.add(branch)
79 await db.commit()
80 return commit_id
81
82
83 async def _create_pr(
84 client: AsyncClient,
85 auth_headers: dict[str, str],
86 repo_id: str,
87 *,
88 title: str = "Add neo-soul keys variation",
89 from_branch: str = "feature",
90 to_branch: str = "main",
91 body: str = "",
92 ) -> dict[str, object]:
93 response = await client.post(
94 f"/api/v1/repos/{repo_id}/pull-requests",
95 json={
96 "title": title,
97 "fromBranch": from_branch,
98 "toBranch": to_branch,
99 "body": body,
100 },
101 headers=auth_headers,
102 )
103 assert response.status_code == 201, response.text
104 return dict(response.json())
105
106
107 # ---------------------------------------------------------------------------
108 # POST /repos/{repo_id}/pull-requests
109 # ---------------------------------------------------------------------------
110
111
112 @pytest.mark.anyio
113 async def test_create_pr_returns_open_state(
114 client: AsyncClient,
115 auth_headers: dict[str, str],
116 db_session: AsyncSession,
117 ) -> None:
118 """PR created via POST returns state='open' with all required fields."""
119 repo_id = await _create_repo(client, auth_headers, "pr-open-state-repo")
120 await _push_branch(db_session, repo_id, "feature")
121
122 response = await client.post(
123 f"/api/v1/repos/{repo_id}/pull-requests",
124 json={
125 "title": "Add neo-soul keys variation",
126 "fromBranch": "feature",
127 "toBranch": "main",
128 "body": "Adds dreamy chord voicings.",
129 },
130 headers=auth_headers,
131 )
132
133 assert response.status_code == 201
134 body = response.json()
135 assert body["state"] == "open"
136 assert body["title"] == "Add neo-soul keys variation"
137 assert body["fromBranch"] == "feature"
138 assert body["toBranch"] == "main"
139 assert body["body"] == "Adds dreamy chord voicings."
140 assert "prId" in body
141 assert "createdAt" in body
142 assert body["mergeCommitId"] is None
143
144
145 @pytest.mark.anyio
146 async def test_create_pr_same_branch_returns_422(
147 client: AsyncClient,
148 auth_headers: dict[str, str],
149 ) -> None:
150 """Creating a PR with from_branch == to_branch returns HTTP 422."""
151 repo_id = await _create_repo(client, auth_headers, "same-branch-repo")
152
153 response = await client.post(
154 f"/api/v1/repos/{repo_id}/pull-requests",
155 json={"title": "Bad PR", "fromBranch": "main", "toBranch": "main"},
156 headers=auth_headers,
157 )
158
159 assert response.status_code == 422
160
161
162 @pytest.mark.anyio
163 async def test_create_pr_missing_from_branch_returns_404(
164 client: AsyncClient,
165 auth_headers: dict[str, str],
166 ) -> None:
167 """Creating a PR when from_branch does not exist returns HTTP 404."""
168 repo_id = await _create_repo(client, auth_headers, "no-branch-repo")
169
170 response = await client.post(
171 f"/api/v1/repos/{repo_id}/pull-requests",
172 json={"title": "Ghost PR", "fromBranch": "nonexistent", "toBranch": "main"},
173 headers=auth_headers,
174 )
175
176 assert response.status_code == 404
177
178
179 @pytest.mark.anyio
180 async def test_create_pr_requires_auth(client: AsyncClient) -> None:
181 """POST /pull-requests returns 401 without a Bearer token."""
182 response = await client.post(
183 "/api/v1/repos/any-id/pull-requests",
184 json={"title": "Unauthorized", "fromBranch": "feat", "toBranch": "main"},
185 )
186 assert response.status_code == 401
187
188
189 # ---------------------------------------------------------------------------
190 # GET /repos/{repo_id}/pull-requests
191 # ---------------------------------------------------------------------------
192
193
194 @pytest.mark.anyio
195 async def test_list_prs_returns_all_states(
196 client: AsyncClient,
197 auth_headers: dict[str, str],
198 db_session: AsyncSession,
199 ) -> None:
200 """GET /pull-requests returns open AND merged PRs by default."""
201 repo_id = await _create_repo(client, auth_headers, "list-all-states-repo")
202 await _push_branch(db_session, repo_id, "feature-a")
203 await _push_branch(db_session, repo_id, "feature-b")
204 await _push_branch(db_session, repo_id, "main")
205
206 pr_a = await _create_pr(
207 client, auth_headers, repo_id, title="Open PR", from_branch="feature-a"
208 )
209 pr_b = await _create_pr(
210 client, auth_headers, repo_id, title="Merged PR", from_branch="feature-b"
211 )
212
213 # Merge pr_b
214 await client.post(
215 f"/api/v1/repos/{repo_id}/pull-requests/{pr_b['prId']}/merge",
216 json={"mergeStrategy": "merge_commit"},
217 headers=auth_headers,
218 )
219
220 response = await client.get(
221 f"/api/v1/repos/{repo_id}/pull-requests",
222 headers=auth_headers,
223 )
224 assert response.status_code == 200
225 prs = response.json()["pullRequests"]
226 assert len(prs) == 2
227 states = {p["state"] for p in prs}
228 assert "open" in states
229 assert "merged" in states
230
231
232 @pytest.mark.anyio
233 async def test_list_prs_filter_by_open(
234 client: AsyncClient,
235 auth_headers: dict[str, str],
236 db_session: AsyncSession,
237 ) -> None:
238 """GET /pull-requests?state=open returns only open PRs."""
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_pr(client, auth_headers, repo_id, title="Open PR", from_branch="feat-open")
245 pr_to_merge = await _create_pr(
246 client, auth_headers, repo_id, title="Will merge", from_branch="feat-merge"
247 )
248 await client.post(
249 f"/api/v1/repos/{repo_id}/pull-requests/{pr_to_merge['prId']}/merge",
250 json={"mergeStrategy": "merge_commit"},
251 headers=auth_headers,
252 )
253
254 response = await client.get(
255 f"/api/v1/repos/{repo_id}/pull-requests?state=open",
256 headers=auth_headers,
257 )
258 assert response.status_code == 200
259 prs = response.json()["pullRequests"]
260 assert len(prs) == 1
261 assert prs[0]["state"] == "open"
262
263
264 @pytest.mark.anyio
265 async def test_list_prs_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
266 """GET /pull-requests returns 404 for non-existent repo without a token.
267
268 Uses optional_token — auth is visibility-based; missing repo → 404.
269 """
270 response = await client.get("/api/v1/repos/non-existent-repo-id/pull-requests")
271 assert response.status_code == 404
272
273
274 # ---------------------------------------------------------------------------
275 # GET /repos/{repo_id}/pull-requests/{pr_id}
276 # ---------------------------------------------------------------------------
277
278
279 @pytest.mark.anyio
280 async def test_get_pr_returns_full_detail(
281 client: AsyncClient,
282 auth_headers: dict[str, str],
283 db_session: AsyncSession,
284 ) -> None:
285 """GET /pull-requests/{pr_id} returns the full PR object."""
286 repo_id = await _create_repo(client, auth_headers, "get-detail-repo")
287 await _push_branch(db_session, repo_id, "keys-variation")
288
289 created = await _create_pr(
290 client,
291 auth_headers,
292 repo_id,
293 title="Keys variation",
294 from_branch="keys-variation",
295 body="Dreamy neo-soul voicings",
296 )
297
298 response = await client.get(
299 f"/api/v1/repos/{repo_id}/pull-requests/{created['prId']}",
300 headers=auth_headers,
301 )
302 assert response.status_code == 200
303 body = response.json()
304 assert body["prId"] == created["prId"]
305 assert body["title"] == "Keys variation"
306 assert body["body"] == "Dreamy neo-soul voicings"
307 assert body["state"] == "open"
308
309
310 @pytest.mark.anyio
311 async def test_get_pr_unknown_id_returns_404(
312 client: AsyncClient,
313 auth_headers: dict[str, str],
314 ) -> None:
315 """GET /pull-requests/{unknown_pr_id} returns 404."""
316 repo_id = await _create_repo(client, auth_headers, "get-404-repo")
317
318 response = await client.get(
319 f"/api/v1/repos/{repo_id}/pull-requests/does-not-exist",
320 headers=auth_headers,
321 )
322 assert response.status_code == 404
323
324
325 @pytest.mark.anyio
326 async def test_get_pr_nonexistent_returns_404_without_auth(client: AsyncClient) -> None:
327 """GET /pull-requests/{pr_id} returns 404 for non-existent resource without a token.
328
329 Uses optional_token — auth is visibility-based; missing repo/PR → 404.
330 """
331 response = await client.get("/api/v1/repos/non-existent-repo/pull-requests/non-existent-pr")
332 assert response.status_code == 404
333
334
335 # ---------------------------------------------------------------------------
336 # POST /repos/{repo_id}/pull-requests/{pr_id}/merge
337 # ---------------------------------------------------------------------------
338
339
340 @pytest.mark.anyio
341 async def test_merge_pr_creates_merge_commit(
342 client: AsyncClient,
343 auth_headers: dict[str, str],
344 db_session: AsyncSession,
345 ) -> None:
346 """Merging a PR creates a merge commit and sets state to 'merged'."""
347 repo_id = await _create_repo(client, auth_headers, "merge-commit-repo")
348 await _push_branch(db_session, repo_id, "neo-soul")
349 await _push_branch(db_session, repo_id, "main")
350
351 pr = await _create_pr(
352 client, auth_headers, repo_id, title="Neo-soul merge", from_branch="neo-soul"
353 )
354
355 response = await client.post(
356 f"/api/v1/repos/{repo_id}/pull-requests/{pr['prId']}/merge",
357 json={"mergeStrategy": "merge_commit"},
358 headers=auth_headers,
359 )
360
361 assert response.status_code == 200
362 body = response.json()
363 assert body["merged"] is True
364 assert "mergeCommitId" in body
365 assert body["mergeCommitId"] is not None
366
367 # Verify PR state changed to merged
368 detail = await client.get(
369 f"/api/v1/repos/{repo_id}/pull-requests/{pr['prId']}",
370 headers=auth_headers,
371 )
372 assert detail.json()["state"] == "merged"
373 assert detail.json()["mergeCommitId"] == body["mergeCommitId"]
374
375
376 @pytest.mark.anyio
377 async def test_merge_already_merged_returns_409(
378 client: AsyncClient,
379 auth_headers: dict[str, str],
380 db_session: AsyncSession,
381 ) -> None:
382 """Merging an already-merged PR returns HTTP 409 Conflict."""
383 repo_id = await _create_repo(client, auth_headers, "double-merge-repo")
384 await _push_branch(db_session, repo_id, "feature-dup")
385 await _push_branch(db_session, repo_id, "main")
386
387 pr = await _create_pr(
388 client, auth_headers, repo_id, title="Duplicate merge", from_branch="feature-dup"
389 )
390
391 # First merge succeeds
392 first = await client.post(
393 f"/api/v1/repos/{repo_id}/pull-requests/{pr['prId']}/merge",
394 json={"mergeStrategy": "merge_commit"},
395 headers=auth_headers,
396 )
397 assert first.status_code == 200
398
399 # Second merge must 409
400 second = await client.post(
401 f"/api/v1/repos/{repo_id}/pull-requests/{pr['prId']}/merge",
402 json={"mergeStrategy": "merge_commit"},
403 headers=auth_headers,
404 )
405 assert second.status_code == 409
406
407
408 @pytest.mark.anyio
409 async def test_merge_pr_requires_auth(client: AsyncClient) -> None:
410 """POST /pull-requests/{pr_id}/merge returns 401 without a Bearer token."""
411 response = await client.post(
412 "/api/v1/repos/r/pull-requests/p/merge",
413 json={"mergeStrategy": "merge_commit"},
414 )
415 assert response.status_code == 401
416
417
418 # ---------------------------------------------------------------------------
419 # Regression tests — author field on PR
420 # ---------------------------------------------------------------------------
421
422
423 @pytest.mark.anyio
424 async def test_create_pr_author_in_response(
425 client: AsyncClient,
426 auth_headers: dict[str, str],
427 db_session: AsyncSession,
428 ) -> None:
429 """POST /pull-requests response includes the author field (JWT sub) — regression f."""
430 repo_id = await _create_repo(client, auth_headers, "author-pr-repo")
431 await _push_branch(db_session, repo_id, "feat/author-test")
432 response = await client.post(
433 f"/api/v1/repos/{repo_id}/pull-requests",
434 json={
435 "title": "Author field regression",
436 "body": "",
437 "fromBranch": "feat/author-test",
438 "toBranch": "main",
439 },
440 headers=auth_headers,
441 )
442 assert response.status_code == 201
443 body = response.json()
444 assert "author" in body
445 assert isinstance(body["author"], str)
446
447
448 @pytest.mark.anyio
449 async def test_create_pr_author_persisted_in_list(
450 client: AsyncClient,
451 auth_headers: dict[str, str],
452 db_session: AsyncSession,
453 ) -> None:
454 """Author field is persisted and returned in the PR list endpoint — regression f."""
455 repo_id = await _create_repo(client, auth_headers, "author-pr-list-repo")
456 await _push_branch(db_session, repo_id, "feat/author-list-test")
457 await client.post(
458 f"/api/v1/repos/{repo_id}/pull-requests",
459 json={
460 "title": "Authored PR",
461 "body": "",
462 "fromBranch": "feat/author-list-test",
463 "toBranch": "main",
464 },
465 headers=auth_headers,
466 )
467 list_response = await client.get(
468 f"/api/v1/repos/{repo_id}/pull-requests",
469 headers=auth_headers,
470 )
471 assert list_response.status_code == 200
472 prs = list_response.json()["pullRequests"]
473 assert len(prs) == 1
474 assert "author" in prs[0]
475 assert isinstance(prs[0]["author"], str)
476
477
478 @pytest.mark.anyio
479 async def test_pr_diff_endpoint_returns_five_dimensions(
480 client: AsyncClient,
481 auth_headers: dict[str, str],
482 db_session: AsyncSession,
483 ) -> None:
484 """GET /pull-requests/{pr_id}/diff returns per-dimension scores for the PR branches."""
485 repo_id = await _create_repo(client, auth_headers, "diff-pr-repo")
486 await _push_branch(db_session, repo_id, "feat/jazz-keys")
487 pr_resp = await _create_pr(client, auth_headers, repo_id, from_branch="feat/jazz-keys", to_branch="main")
488 pr_id = pr_resp["prId"]
489
490 response = await client.get(
491 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/diff",
492 headers=auth_headers,
493 )
494 assert response.status_code == 200
495 data = response.json()
496 assert "dimensions" in data
497 assert len(data["dimensions"]) == 5
498 assert data["prId"] == pr_id
499 assert data["fromBranch"] == "feat/jazz-keys"
500 assert data["toBranch"] == "main"
501 assert "overallScore" in data
502 assert isinstance(data["overallScore"], float)
503
504 # Every dimension must have the expected fields
505 for dim in data["dimensions"]:
506 assert "dimension" in dim
507 assert dim["dimension"] in ("melodic", "harmonic", "rhythmic", "structural", "dynamic")
508 assert "score" in dim
509 assert 0.0 <= dim["score"] <= 1.0
510 assert "level" in dim
511 assert dim["level"] in ("NONE", "LOW", "MED", "HIGH")
512 assert "deltaLabel" in dim
513 assert "fromBranchCommits" in dim
514 assert "toBranchCommits" in dim
515
516
517 @pytest.mark.anyio
518 async def test_pr_diff_endpoint_404_for_unknown_pr(
519 client: AsyncClient,
520 auth_headers: dict[str, str],
521 db_session: AsyncSession,
522 ) -> None:
523 """GET /pull-requests/{pr_id}/diff returns 404 when the PR does not exist."""
524 repo_id = await _create_repo(client, auth_headers, "diff-404-repo")
525 response = await client.get(
526 f"/api/v1/repos/{repo_id}/pull-requests/nonexistent-pr-id/diff",
527 headers=auth_headers,
528 )
529 assert response.status_code == 404
530
531
532 @pytest.mark.anyio
533 async def test_pr_diff_endpoint_graceful_when_no_commits(
534 client: AsyncClient,
535 auth_headers: dict[str, str],
536 db_session: AsyncSession,
537 ) -> None:
538 """Diff endpoint returns zero scores when branches have no commits (graceful degradation).
539
540 When from_branch has commits but to_branch ('main') has none, compute_hub_divergence
541 raises ValueError. The diff endpoint must catch it and return zero-score placeholders
542 so the PR detail page always renders.
543 """
544 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubPullRequest
545
546 repo_id = await _create_repo(client, auth_headers, "diff-empty-repo")
547
548 # Seed from_branch with a commit so the PR can be created.
549 commit_id = uuid.uuid4().hex
550 commit = MusehubCommit(
551 commit_id=commit_id,
552 repo_id=repo_id,
553 branch="feat/empty-grace",
554 parent_ids=[],
555 message="Initial commit on feat/empty-grace",
556 author="musician",
557 timestamp=datetime.now(tz=timezone.utc),
558 )
559 branch = MusehubBranch(
560 repo_id=repo_id,
561 name="feat/empty-grace",
562 head_commit_id=commit_id,
563 )
564 db_session.add(commit)
565 db_session.add(branch)
566
567 # to_branch 'main' deliberately has NO commits — divergence will raise ValueError.
568 pr = MusehubPullRequest(
569 repo_id=repo_id,
570 title="Grace PR",
571 body="",
572 state="open",
573 from_branch="feat/empty-grace",
574 to_branch="main",
575 author="musician",
576 )
577 db_session.add(pr)
578 await db_session.flush()
579 await db_session.refresh(pr)
580 pr_id = pr.pr_id
581 await db_session.commit()
582
583 response = await client.get(
584 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/diff",
585 headers=auth_headers,
586 )
587 assert response.status_code == 200
588 data = response.json()
589 assert len(data["dimensions"]) == 5
590 assert data["overallScore"] == 0.0
591 for dim in data["dimensions"]:
592 assert dim["score"] == 0.0
593 assert dim["level"] == "NONE"
594 assert dim["deltaLabel"] == "unchanged"
595
596
597 @pytest.mark.anyio
598 async def test_pr_merge_strategy_squash_accepted(
599 client: AsyncClient,
600 auth_headers: dict[str, str],
601 db_session: AsyncSession,
602 ) -> None:
603 """POST /pull-requests/{pr_id}/merge accepts 'squash' as a valid mergeStrategy."""
604 repo_id = await _create_repo(client, auth_headers, "strategy-squash-repo")
605 await _push_branch(db_session, repo_id, "feat/squash-test")
606 await _push_branch(db_session, repo_id, "main")
607 pr_resp = await _create_pr(client, auth_headers, repo_id, from_branch="feat/squash-test", to_branch="main")
608 pr_id = pr_resp["prId"]
609
610 response = await client.post(
611 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/merge",
612 json={"mergeStrategy": "squash"},
613 headers=auth_headers,
614 )
615 # squash is now a valid strategy in the Pydantic model; merge logic uses merge_commit internally
616 assert response.status_code == 200
617 data = response.json()
618 assert data["merged"] is True
619
620
621 @pytest.mark.anyio
622 async def test_pr_merge_strategy_rebase_accepted(
623 client: AsyncClient,
624 auth_headers: dict[str, str],
625 db_session: AsyncSession,
626 ) -> None:
627 """POST /pull-requests/{pr_id}/merge accepts 'rebase' as a valid mergeStrategy."""
628 repo_id = await _create_repo(client, auth_headers, "strategy-rebase-repo")
629 await _push_branch(db_session, repo_id, "feat/rebase-test")
630 await _push_branch(db_session, repo_id, "main")
631 pr_resp = await _create_pr(client, auth_headers, repo_id, from_branch="feat/rebase-test", to_branch="main")
632 pr_id = pr_resp["prId"]
633
634 response = await client.post(
635 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/merge",
636 json={"mergeStrategy": "rebase"},
637 headers=auth_headers,
638 )
639 assert response.status_code == 200
640 data = response.json()
641 assert data["merged"] is True
642
643
644 # ---------------------------------------------------------------------------
645 # PR review comments — # ---------------------------------------------------------------------------
646
647
648 @pytest.mark.anyio
649 async def test_create_pr_comment(
650 client: AsyncClient,
651 auth_headers: dict[str, str],
652 db_session: AsyncSession,
653 ) -> None:
654 """POST /pull-requests/{pr_id}/comments creates a comment and returns threaded list."""
655 repo_id = await _create_repo(client, auth_headers, "comment-create-repo")
656 await _push_branch(db_session, repo_id, "feat/comment-test")
657 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/comment-test")
658
659 response = await client.post(
660 f"/api/v1/repos/{repo_id}/pull-requests/{pr['prId']}/comments",
661 json={"body": "The bass line feels stiff — add swing.", "targetType": "general"},
662 headers=auth_headers,
663 )
664 assert response.status_code == 201
665 data = response.json()
666 assert "comments" in data
667 assert "total" in data
668 assert data["total"] == 1
669 comment = data["comments"][0]
670 assert comment["body"] == "The bass line feels stiff — add swing."
671 assert comment["targetType"] == "general"
672 assert "commentId" in comment
673 assert "createdAt" in comment
674
675
676 @pytest.mark.anyio
677 async def test_list_pr_comments_threaded(
678 client: AsyncClient,
679 auth_headers: dict[str, str],
680 db_session: AsyncSession,
681 ) -> None:
682 """GET /pull-requests/{pr_id}/comments returns top-level comments with nested replies."""
683 repo_id = await _create_repo(client, auth_headers, "comment-list-repo")
684 await _push_branch(db_session, repo_id, "feat/list-comments")
685 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/list-comments")
686 pr_id = pr["prId"]
687
688 # Create a top-level comment
689 create_resp = await client.post(
690 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/comments",
691 json={"body": "Top-level comment.", "targetType": "general"},
692 headers=auth_headers,
693 )
694 assert create_resp.status_code == 201
695 parent_id = create_resp.json()["comments"][0]["commentId"]
696
697 # Reply to it
698 reply_resp = await client.post(
699 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/comments",
700 json={"body": "A reply.", "targetType": "general", "parentCommentId": parent_id},
701 headers=auth_headers,
702 )
703 assert reply_resp.status_code == 201
704
705 # Fetch threaded list
706 list_resp = await client.get(
707 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/comments",
708 headers=auth_headers,
709 )
710 assert list_resp.status_code == 200
711 data = list_resp.json()
712 assert data["total"] == 2
713 # Only one top-level comment
714 assert len(data["comments"]) == 1
715 top = data["comments"][0]
716 assert len(top["replies"]) == 1
717 assert top["replies"][0]["body"] == "A reply."
718
719
720 @pytest.mark.anyio
721 async def test_comment_targets_track(
722 client: AsyncClient,
723 auth_headers: dict[str, str],
724 db_session: AsyncSession,
725 ) -> None:
726 """POST /comments with target_type=region stores track and beat range correctly."""
727 repo_id = await _create_repo(client, auth_headers, "comment-track-repo")
728 await _push_branch(db_session, repo_id, "feat/track-comment")
729 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/track-comment")
730
731 response = await client.post(
732 f"/api/v1/repos/{repo_id}/pull-requests/{pr['prId']}/comments",
733 json={
734 "body": "Beats 16-24 on bass feel rushed.",
735 "targetType": "region",
736 "targetTrack": "bass",
737 "targetBeatStart": 16.0,
738 "targetBeatEnd": 24.0,
739 },
740 headers=auth_headers,
741 )
742 assert response.status_code == 201
743 comment = response.json()["comments"][0]
744 assert comment["targetType"] == "region"
745 assert comment["targetTrack"] == "bass"
746 assert comment["targetBeatStart"] == 16.0
747 assert comment["targetBeatEnd"] == 24.0
748
749
750 @pytest.mark.anyio
751 async def test_comment_requires_auth(client: AsyncClient) -> None:
752 """POST /pull-requests/{pr_id}/comments returns 401 without a Bearer token."""
753 response = await client.post(
754 "/api/v1/repos/r/pull-requests/p/comments",
755 json={"body": "Unauthorized attempt."},
756 )
757 assert response.status_code == 401
758
759
760 @pytest.mark.anyio
761 async def test_reply_to_comment(
762 client: AsyncClient,
763 auth_headers: dict[str, str],
764 db_session: AsyncSession,
765 ) -> None:
766 """Replying to a comment creates a threaded child visible in the list."""
767 repo_id = await _create_repo(client, auth_headers, "comment-reply-repo")
768 await _push_branch(db_session, repo_id, "feat/reply-test")
769 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/reply-test")
770 pr_id = pr["prId"]
771
772 parent_resp = await client.post(
773 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/comments",
774 json={"body": "Original comment.", "targetType": "general"},
775 headers=auth_headers,
776 )
777 parent_id = parent_resp.json()["comments"][0]["commentId"]
778
779 reply_resp = await client.post(
780 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/comments",
781 json={"body": "Reply here.", "targetType": "general", "parentCommentId": parent_id},
782 headers=auth_headers,
783 )
784 assert reply_resp.status_code == 201
785 data = reply_resp.json()
786 # Still only one top-level comment; total is 2
787 assert data["total"] == 2
788 assert len(data["comments"]) == 1
789 reply = data["comments"][0]["replies"][0]
790 assert reply["body"] == "Reply here."
791 assert reply["parentCommentId"] == parent_id
792
793
794 # ---------------------------------------------------------------------------
795 # Issue #384 — affected_sections and divergence service helpers
796 # ---------------------------------------------------------------------------
797
798
799 def test_extract_affected_sections_returns_empty_when_no_keywords() -> None:
800 """affected_sections is empty when no commit mentions a section keyword."""
801 from musehub.services.musehub_divergence import extract_affected_sections
802
803 messages: tuple[str, ...] = (
804 "add jazzy chord voicing",
805 "fix drum quantization",
806 "update harmonic progression",
807 )
808 assert extract_affected_sections(messages) == []
809
810
811 def test_extract_affected_sections_returns_only_mentioned_keywords() -> None:
812 """affected_sections lists only the sections actually named in commits."""
813 from musehub.services.musehub_divergence import extract_affected_sections
814
815 messages: tuple[str, ...] = (
816 "rework the chorus melody",
817 "add a new bridge transition",
818 "fix drum quantization",
819 )
820 result = extract_affected_sections(messages)
821 assert "Chorus" in result
822 assert "Bridge" in result
823 assert "Verse" not in result
824 assert "Intro" not in result
825 assert "Outro" not in result
826
827
828 def test_extract_affected_sections_case_insensitive() -> None:
829 """Keyword matching is case-insensitive."""
830 from musehub.services.musehub_divergence import extract_affected_sections
831
832 messages: tuple[str, ...] = ("rewrite VERSE chord progression",)
833 result = extract_affected_sections(messages)
834 assert result == ["Verse"]
835
836
837 def test_extract_affected_sections_deduplicates() -> None:
838 """The same keyword appearing in multiple commits is only returned once."""
839 from musehub.services.musehub_divergence import extract_affected_sections
840
841 messages: tuple[str, ...] = (
842 "update chorus dynamics",
843 "fix chorus timing",
844 "tweak chorus reverb",
845 )
846 result = extract_affected_sections(messages)
847 assert result.count("Chorus") == 1
848
849
850 def test_build_zero_diff_response_structure() -> None:
851 """build_zero_diff_response returns five dimensions all at score 0.0."""
852 from musehub.services.musehub_divergence import ALL_DIMENSIONS, build_zero_diff_response
853
854 resp = build_zero_diff_response(
855 pr_id="pr-abc",
856 repo_id="repo-xyz",
857 from_branch="feat/test",
858 to_branch="main",
859 )
860 assert resp.pr_id == "pr-abc"
861 assert resp.repo_id == "repo-xyz"
862 assert resp.from_branch == "feat/test"
863 assert resp.to_branch == "main"
864 assert resp.overall_score == 0.0
865 assert resp.common_ancestor is None
866 assert resp.affected_sections == []
867 assert len(resp.dimensions) == len(ALL_DIMENSIONS)
868 for dim in resp.dimensions:
869 assert dim.score == 0.0
870 assert dim.level == "NONE"
871 assert dim.delta_label == "unchanged"
872
873
874 def test_build_pr_diff_response_affected_sections_uses_commit_messages() -> None:
875 """build_pr_diff_response derives affected_sections from commit messages, not score heuristic."""
876 from musehub.services.musehub_divergence import (
877 MuseHubDimensionDivergence,
878 MuseHubDivergenceLevel,
879 MuseHubDivergenceResult,
880 build_pr_diff_response,
881 )
882
883 # Structural score > 0, but NO section keyword in any commit message.
884 structural_dim = MuseHubDimensionDivergence(
885 dimension="structural",
886 level=MuseHubDivergenceLevel.LOW,
887 score=0.3,
888 description="Minor structural divergence.",
889 branch_a_commits=1,
890 branch_b_commits=0,
891 )
892 result = MuseHubDivergenceResult(
893 repo_id="repo-1",
894 branch_a="main",
895 branch_b="feat/changes",
896 common_ancestor="abc123",
897 dimensions=(structural_dim,),
898 overall_score=0.3,
899 all_messages=("refactor arrangement flow", "update drum pattern"),
900 )
901 resp = build_pr_diff_response(
902 pr_id="pr-1",
903 from_branch="feat/changes",
904 to_branch="main",
905 result=result,
906 )
907 # No section keyword in commit messages → empty list, even though structural score > 0
908 assert resp.affected_sections == []
909
910
911 # ---------------------------------------------------------------------------
912 # PR reviewer assignment endpoints — # ---------------------------------------------------------------------------
913
914
915 @pytest.mark.anyio
916 async def test_request_reviewers_creates_pending_rows(
917 client: AsyncClient,
918 auth_headers: dict[str, str],
919 db_session: AsyncSession,
920 ) -> None:
921 """POST /reviewers creates pending review rows for each requested username."""
922 repo_id = await _create_repo(client, auth_headers, "reviewer-create-repo")
923 await _push_branch(db_session, repo_id, "feat/reviewer-test")
924 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/reviewer-test")
925 pr_id = pr["prId"]
926
927 response = await client.post(
928 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers",
929 json={"reviewers": ["alice", "bob"]},
930 headers=auth_headers,
931 )
932 assert response.status_code == 201
933 data = response.json()
934 assert "reviews" in data
935 assert data["total"] == 2
936 usernames = {r["reviewerUsername"] for r in data["reviews"]}
937 assert usernames == {"alice", "bob"}
938 for review in data["reviews"]:
939 assert review["state"] == "pending"
940 assert review["submittedAt"] is None
941
942
943 @pytest.mark.anyio
944 async def test_request_reviewers_idempotent(
945 client: AsyncClient,
946 auth_headers: dict[str, str],
947 db_session: AsyncSession,
948 ) -> None:
949 """Re-requesting the same reviewer does not create a duplicate row."""
950 repo_id = await _create_repo(client, auth_headers, "reviewer-idempotent-repo")
951 await _push_branch(db_session, repo_id, "feat/idempotent")
952 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/idempotent")
953 pr_id = pr["prId"]
954
955 await client.post(
956 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers",
957 json={"reviewers": ["alice"]},
958 headers=auth_headers,
959 )
960 # Second request for the same reviewer
961 response = await client.post(
962 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers",
963 json={"reviewers": ["alice"]},
964 headers=auth_headers,
965 )
966 assert response.status_code == 201
967 assert response.json()["total"] == 1 # still only one row
968
969
970 @pytest.mark.anyio
971 async def test_request_reviewers_requires_auth(client: AsyncClient) -> None:
972 """POST /reviewers returns 401 without a Bearer token."""
973 response = await client.post(
974 "/api/v1/repos/r/pull-requests/p/reviewers",
975 json={"reviewers": ["alice"]},
976 )
977 assert response.status_code == 401
978
979
980 @pytest.mark.anyio
981 async def test_remove_reviewer_deletes_pending_row(
982 client: AsyncClient,
983 auth_headers: dict[str, str],
984 db_session: AsyncSession,
985 ) -> None:
986 """DELETE /reviewers/{username} removes a pending reviewer assignment."""
987 repo_id = await _create_repo(client, auth_headers, "reviewer-delete-repo")
988 await _push_branch(db_session, repo_id, "feat/remove-reviewer")
989 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/remove-reviewer")
990 pr_id = pr["prId"]
991
992 await client.post(
993 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers",
994 json={"reviewers": ["alice", "bob"]},
995 headers=auth_headers,
996 )
997
998 response = await client.delete(
999 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers/alice",
1000 headers=auth_headers,
1001 )
1002 assert response.status_code == 200
1003 data = response.json()
1004 assert data["total"] == 1
1005 assert data["reviews"][0]["reviewerUsername"] == "bob"
1006
1007
1008 @pytest.mark.anyio
1009 async def test_remove_reviewer_not_found_returns_404(
1010 client: AsyncClient,
1011 auth_headers: dict[str, str],
1012 db_session: AsyncSession,
1013 ) -> None:
1014 """DELETE /reviewers/{username} returns 404 when the reviewer was never requested."""
1015 repo_id = await _create_repo(client, auth_headers, "reviewer-404-repo")
1016 await _push_branch(db_session, repo_id, "feat/remove-404")
1017 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/remove-404")
1018 pr_id = pr["prId"]
1019
1020 response = await client.delete(
1021 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers/nobody",
1022 headers=auth_headers,
1023 )
1024 assert response.status_code == 404
1025
1026
1027 # ---------------------------------------------------------------------------
1028 # PR review submission endpoints — # ---------------------------------------------------------------------------
1029
1030
1031 @pytest.mark.anyio
1032 async def test_list_reviews_empty_for_new_pr(
1033 client: AsyncClient,
1034 auth_headers: dict[str, str],
1035 db_session: AsyncSession,
1036 ) -> None:
1037 """GET /reviews returns an empty list for a PR with no reviews assigned."""
1038 repo_id = await _create_repo(client, auth_headers, "reviews-empty-repo")
1039 await _push_branch(db_session, repo_id, "feat/list-reviews-empty")
1040 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/list-reviews-empty")
1041 pr_id = pr["prId"]
1042
1043 response = await client.get(
1044 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1045 headers=auth_headers,
1046 )
1047 assert response.status_code == 200
1048 data = response.json()
1049 assert data["total"] == 0
1050 assert data["reviews"] == []
1051
1052
1053 @pytest.mark.anyio
1054 async def test_list_reviews_filter_by_state(
1055 client: AsyncClient,
1056 auth_headers: dict[str, str],
1057 db_session: AsyncSession,
1058 ) -> None:
1059 """GET /reviews?state=pending returns only pending reviews."""
1060 repo_id = await _create_repo(client, auth_headers, "reviews-filter-repo")
1061 await _push_branch(db_session, repo_id, "feat/filter-state")
1062 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/filter-state")
1063 pr_id = pr["prId"]
1064
1065 await client.post(
1066 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers",
1067 json={"reviewers": ["alice", "bob"]},
1068 headers=auth_headers,
1069 )
1070
1071 response = await client.get(
1072 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews?state=pending",
1073 headers=auth_headers,
1074 )
1075 assert response.status_code == 200
1076 data = response.json()
1077 assert data["total"] == 2
1078 for r in data["reviews"]:
1079 assert r["state"] == "pending"
1080
1081
1082 @pytest.mark.anyio
1083 async def test_submit_review_approve(
1084 client: AsyncClient,
1085 auth_headers: dict[str, str],
1086 db_session: AsyncSession,
1087 ) -> None:
1088 """POST /reviews with event=approve sets state to approved and records submitted_at."""
1089 repo_id = await _create_repo(client, auth_headers, "review-approve-repo")
1090 await _push_branch(db_session, repo_id, "feat/approve-test")
1091 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/approve-test")
1092 pr_id = pr["prId"]
1093
1094 response = await client.post(
1095 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1096 json={"event": "approve", "body": "Sounds great — the harmonic transitions are perfect."},
1097 headers=auth_headers,
1098 )
1099 assert response.status_code == 201
1100 data = response.json()
1101 assert data["state"] == "approved"
1102 assert data["submittedAt"] is not None
1103 assert "Sounds great" in (data["body"] or "")
1104
1105
1106 @pytest.mark.anyio
1107 async def test_submit_review_request_changes(
1108 client: AsyncClient,
1109 auth_headers: dict[str, str],
1110 db_session: AsyncSession,
1111 ) -> None:
1112 """POST /reviews with event=request_changes sets state to changes_requested."""
1113 repo_id = await _create_repo(client, auth_headers, "review-changes-repo")
1114 await _push_branch(db_session, repo_id, "feat/changes-test")
1115 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/changes-test")
1116 pr_id = pr["prId"]
1117
1118 response = await client.post(
1119 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1120 json={"event": "request_changes", "body": "The bridge needs more harmonic tension."},
1121 headers=auth_headers,
1122 )
1123 assert response.status_code == 201
1124 data = response.json()
1125 assert data["state"] == "changes_requested"
1126 assert data["submittedAt"] is not None
1127
1128
1129 @pytest.mark.anyio
1130 async def test_submit_review_updates_existing_row(
1131 client: AsyncClient,
1132 auth_headers: dict[str, str],
1133 db_session: AsyncSession,
1134 ) -> None:
1135 """Submitting a second review replaces the existing row state in-place."""
1136 repo_id = await _create_repo(client, auth_headers, "review-update-repo")
1137 await _push_branch(db_session, repo_id, "feat/update-review")
1138 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/update-review")
1139 pr_id = pr["prId"]
1140
1141 # First: request changes
1142 await client.post(
1143 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1144 json={"event": "request_changes", "body": "Not happy with the bridge."},
1145 headers=auth_headers,
1146 )
1147
1148 # After author fixes, reviewer now approves
1149 response = await client.post(
1150 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1151 json={"event": "approve", "body": "Looks good now!"},
1152 headers=auth_headers,
1153 )
1154 assert response.status_code == 201
1155 data = response.json()
1156 assert data["state"] == "approved"
1157
1158 # Only one review row should exist
1159 list_resp = await client.get(
1160 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1161 headers=auth_headers,
1162 )
1163 assert list_resp.json()["total"] == 1
1164
1165
1166 @pytest.mark.anyio
1167 async def test_remove_reviewer_after_submit_returns_409(
1168 client: AsyncClient,
1169 auth_headers: dict[str, str],
1170 db_session: AsyncSession,
1171 ) -> None:
1172 """DELETE /reviewers/{username} returns 409 when reviewer already submitted a review.
1173
1174 The test JWT sub is the user UUID '550e8400-e29b-41d4-a716-446655440000'.
1175 Submitting a review via POST /reviews creates a row with that UUID as
1176 reviewer_username, and state=approved. Attempting to DELETE that reviewer
1177 must return 409 because the row is no longer pending.
1178 """
1179 test_jwt_sub = "550e8400-e29b-41d4-a716-446655440000"
1180
1181 repo_id = await _create_repo(client, auth_headers, "reviewer-submitted-repo")
1182 await _push_branch(db_session, repo_id, "feat/submitted-review")
1183 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/submitted-review")
1184 pr_id = pr["prId"]
1185
1186 # Submit a review — this creates an "approved" row for the JWT sub
1187 submit_resp = await client.post(
1188 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1189 json={"event": "approve", "body": "Approved"},
1190 headers=auth_headers,
1191 )
1192 assert submit_resp.status_code == 201
1193
1194 # Attempting to remove the reviewer whose row is already approved must return 409
1195 response = await client.delete(
1196 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviewers/{test_jwt_sub}",
1197 headers=auth_headers,
1198 )
1199 assert response.status_code == 409
1200
1201
1202 @pytest.mark.anyio
1203 async def test_submit_review_invalid_event_returns_422(
1204 client: AsyncClient,
1205 auth_headers: dict[str, str],
1206 db_session: AsyncSession,
1207 ) -> None:
1208 """POST /reviews with an invalid event value returns 422 Unprocessable Entity."""
1209 repo_id = await _create_repo(client, auth_headers, "review-invalid-event-repo")
1210 await _push_branch(db_session, repo_id, "feat/invalid-event")
1211 pr = await _create_pr(client, auth_headers, repo_id, from_branch="feat/invalid-event")
1212 pr_id = pr["prId"]
1213
1214 response = await client.post(
1215 f"/api/v1/repos/{repo_id}/pull-requests/{pr_id}/reviews",
1216 json={"event": "INVALID", "body": ""},
1217 headers=auth_headers,
1218 )
1219 assert response.status_code == 422
1220
1221
1222 def test_build_pr_diff_response_affected_sections_non_empty_when_keywords_present() -> None:
1223 """build_pr_diff_response populates affected_sections from commit message keywords."""
1224 from musehub.services.musehub_divergence import (
1225 MuseHubDimensionDivergence,
1226 MuseHubDivergenceLevel,
1227 MuseHubDivergenceResult,
1228 build_pr_diff_response,
1229 )
1230
1231 structural_dim = MuseHubDimensionDivergence(
1232 dimension="structural",
1233 level=MuseHubDivergenceLevel.LOW,
1234 score=0.3,
1235 description="Minor structural divergence.",
1236 branch_a_commits=2,
1237 branch_b_commits=1,
1238 )
1239 result = MuseHubDivergenceResult(
1240 repo_id="repo-2",
1241 branch_a="main",
1242 branch_b="feat/rewrite",
1243 common_ancestor="def456",
1244 dimensions=(structural_dim,),
1245 overall_score=0.3,
1246 all_messages=("add new verse section", "polish intro melody"),
1247 )
1248 resp = build_pr_diff_response(
1249 pr_id="pr-2",
1250 from_branch="feat/rewrite",
1251 to_branch="main",
1252 result=result,
1253 )
1254 assert "Verse" in resp.affected_sections
1255 assert "Intro" in resp.affected_sections
1256 assert "Chorus" not in resp.affected_sections