gabriel / musehub public
repos.py python
1,564 lines 56.5 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 99 days ago
1 """MuseHub repo, branch, commit, credits, and agent context route handlers.
2
3 Endpoint summary:
4 POST /api/repos — create a new remote repo
5 POST /repos/{repo_id}/fork — fork a public repo into the caller's account
6 GET /repos/{repo_id}/forks — list direct forks of a repo
7 GET /repos/{repo_id}/fork-network — recursive fork network tree
8 GET /repos/{repo_id} — get repo metadata (by internal ID)
9 DELETE /repos/{repo_id} — soft-delete a repo (owner only)
10 POST /repos/{repo_id}/transfer — transfer repo ownership (owner only)
11 GET /musehub/{owner}/{repo_slug} — get repo metadata (by owner/slug)
12 GET /repos/{repo_id}/branches — list all branches
13 POST /repos/{repo_id}/branches/{name}/repair — heal DB branch pointer from disk ref
14 GET /repos/{repo_id}/commits — list commits (newest first)
15 GET /repos/{repo_id}/commits/{sha}/render-status — render job status for a commit
16 GET /repos/{repo_id}/credits — aggregated contributor credits
17 GET /repos/{repo_id}/context — agent context briefing
18 GET /repos/{repo_id}/timeline — chronological timeline with emotion/section/track layers
19 GET /repos/{repo_id}/form-structure/{ref} — form and structure analysis
20 POST /repos/{repo_id}/sessions — push a recording session
21 GET /repos/{repo_id}/sessions — list recording sessions
22 GET /repos/{repo_id}/sessions/{session_id} — get a single session
23 GET /repos/{repo_id}/arrange/{ref} — arrangement matrix (instrument × section grid)
24 All endpoints require a valid MSign token.
25 No business logic lives here — all persistence is delegated to
26 musehub.services.musehub_repository, musehub.services.musehub_credits,
27 and musehub.services.musehub_context.
28 """
29
30 import logging
31
32 import yaml
33 from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
34 from fastapi.responses import JSONResponse
35 from musehub.api.routes.musehub.pagination import build_cursor_link_header
36 from musehub.api.validation import BranchParam, FilePathParam, SlugParam
37 from sqlalchemy import select
38 from sqlalchemy.exc import IntegrityError
39 from sqlalchemy.ext.asyncio import AsyncSession
40
41 from musehub.auth.dependencies import TokenClaims, optional_token, require_scope, require_valid_token
42 from musehub.db import get_db
43 from musehub.db import musehub_collaborator_models as collab_models
44 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit
45 from musehub.types.json_types import StrDict
46 from musehub.models.musehub import (
47 BranchDetailListResponse,
48 BranchListResponse,
49 BranchResetRequest,
50 BranchResetResponse,
51 CollaboratorAccessResponse,
52 CommitDiffDimensionScore,
53 CommitDiffSummaryResponse,
54 CommitListResponse,
55 CommitResponse,
56 CompareResponse,
57 EmotionDiff,
58 CreateRepoRequest,
59 ForkNetworkResponse,
60 ForkRepoRequest,
61 UserForkedRepoEntry,
62 UserForksResponse,
63 DivergenceDimensionResponse,
64 DivergenceResponse,
65 TimelineResponse,
66 DagGraphResponse,
67 GrooveCheckResponse,
68 GrooveCommitEntry,
69 MuseHubContextResponse,
70 RepoListResponse,
71 RepoResponse,
72 RepoSettingsPatch,
73 RepoSettingsResponse,
74 RepoStatsResponse,
75 CreditsResponse,
76 SessionCreate,
77 SessionListResponse,
78 SessionResponse,
79 SessionStop,
80 TransferOwnershipRequest,
81 )
82 from musehub.models.musehub_context import (
83 ContextDepth,
84 ContextFormat,
85 )
86 from musehub.services import musehub_context, musehub_credits, musehub_divergence, musehub_releases, musehub_repository, musehub_sessions
87
88 logger = logging.getLogger(__name__)
89
90 router = APIRouter()
91
92
93 def _guard_visibility(repo: RepoResponse | None, claims: TokenClaims | None) -> None:
94 """Raise 404 when the repo doesn't exist; 401 when a private repo is accessed without auth."""
95 if repo is None:
96 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
97 if repo.visibility == "private" and claims is None:
98 raise HTTPException(
99 status_code=status.HTTP_401_UNAUTHORIZED,
100 detail="Authentication required to access this private repository",
101 headers={"WWW-Authenticate": "MSign"},
102 )
103
104
105 @router.get(
106 "/repos",
107 response_model=RepoListResponse,
108 operation_id="listMyRepos",
109 summary="List repos for the authenticated user (own + collaborated)",
110 tags=["Repos"],
111 )
112 async def list_my_repos(
113 request: Request,
114 response: Response,
115 limit: int = Query(20, ge=1, le=100, description="Max repos per page"),
116 cursor: str | None = Query(None, description="Pagination cursor from a previous response"),
117 db: AsyncSession = Depends(get_db),
118 claims: TokenClaims = Depends(require_valid_token),
119 ) -> RepoListResponse:
120 """Return repos owned by or collaborated on by the authenticated user.
121
122 Results are ordered newest-first. Pass the ``nextCursor`` value from the
123 previous response as ``?cursor=`` to advance through subsequent pages.
124 An absent ``nextCursor`` in the response means you have reached the last page.
125
126 When ``nextCursor`` is present the response also includes an RFC 8288
127 ``Link: <url>; rel="next"`` header so that machine clients can follow
128 pagination using standard HTTP link-following without inspecting the body.
129
130 Auth: requires a valid MSign token.
131 """
132 user_id: str = claims.handle
133 result = await musehub_repository.list_repos_for_user(db, user_id, limit=limit, cursor=cursor)
134 if result.next_cursor is not None:
135 response.headers["Link"] = build_cursor_link_header(request, result.next_cursor, limit)
136 return result
137
138
139 @router.post(
140 "/repos",
141 response_model=RepoResponse,
142 status_code=status.HTTP_201_CREATED,
143 operation_id="createRepo",
144 summary="Create a remote Muse repo",
145 tags=["Repos"],
146 )
147 async def create_repo(
148 body: CreateRepoRequest,
149 db: AsyncSession = Depends(get_db),
150 claims: TokenClaims = Depends(require_scope("repo:write")),
151 ) -> RepoResponse:
152 """Create a new remote MuseHub repository owned by the authenticated user.
153
154 ``slug`` is auto-generated from ``name``. Returns 409 if the ``(owner, slug)``
155 pair already exists — the musician must rename the repo to get a distinct slug.
156
157 Wizard behaviors (from the request body):
158 - ``initialize=true``: an empty "Initial commit" + default branch are created
159 immediately so the repo is browsable right away.
160 - ``template_repo_id``: topics/description are copied from a public template repo.
161 - ``license``: stored in the repo settings blob.
162 - ``topics``: merged with ``tags`` into a single tag list.
163
164 Clone URL: ``musehub://{owner}/{slug}``
165 """
166 owner_user_id: str = claims.handle
167 try:
168 repo = await musehub_repository.create_repo(
169 db,
170 name=body.name,
171 owner=body.owner,
172 visibility=body.visibility,
173 owner_user_id=owner_user_id,
174 owner_identity_id=claims.identity_id,
175 domain=getattr(body, "domain", "") or "",
176 description=body.description,
177 tags=body.tags,
178 license=body.license,
179 topics=body.topics,
180 initialize=body.initialize,
181 default_branch=body.default_branch,
182 template_repo_id=body.template_repo_id,
183 )
184 # Seed default labels for every new repo so agents and humans can
185 # immediately categorise issues without a separate label-create step.
186 from musehub.api.routes.musehub.labels import seed_default_labels
187 await seed_default_labels(db, repo.repo_id)
188 await db.commit()
189 except IntegrityError:
190 await db.rollback()
191 raise HTTPException(
192 status_code=status.HTTP_409_CONFLICT,
193 detail="A repo with this owner and name already exists",
194 )
195 return repo
196
197
198 @router.post(
199 "/repos/{repo_id}/fork",
200 response_model=UserForkedRepoEntry,
201 status_code=status.HTTP_201_CREATED,
202 operation_id="forkRepo",
203 summary="Fork a public repository",
204 tags=["Repos"],
205 )
206 async def fork_repo(
207 repo_id: str,
208 body: ForkRepoRequest,
209 db: AsyncSession = Depends(get_db),
210 claims: TokenClaims = Depends(require_scope("repo:write")),
211 ) -> UserForkedRepoEntry:
212 """Fork a repository into the authenticated caller's account.
213
214 Creates a new public repository owned by the caller and records the fork
215 relationship in ``musehub_forks``.
216
217 Rules:
218 - The source repo must exist.
219 - A caller cannot fork their own repo.
220 - A caller cannot fork the same repo twice (409 on duplicate).
221
222 Returns the newly created fork with full repo metadata and source
223 attribution so clients can immediately render the canonical
224 "forked from {owner}/{slug}" attribution line.
225
226 Raises:
227 401: Missing or invalid MSign token.
228 403: Caller owns the source repo.
229 404: Source repo not found.
230 409: Caller already has a fork of this repo.
231 """
232 forked_by = claims.handle
233 try:
234 entry = await musehub_repository.fork_repo(
235 db,
236 source_repo_id=repo_id,
237 forked_by_handle=forked_by,
238 request=body,
239 )
240 await db.commit()
241 except ValueError as exc:
242 await db.rollback()
243 msg = str(exc)
244 if msg == "source_repo_not_found":
245 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
246 if msg == "source_repo_private":
247 raise HTTPException(
248 status_code=status.HTTP_403_FORBIDDEN,
249 detail="Cannot fork a private repository — only public repositories can be forked",
250 )
251 if msg == "cannot_fork_own_repo":
252 raise HTTPException(
253 status_code=status.HTTP_403_FORBIDDEN,
254 detail="You cannot fork a repository you already own",
255 )
256 if msg == "duplicate_fork":
257 raise HTTPException(
258 status_code=status.HTTP_409_CONFLICT,
259 detail="You have already forked this repository",
260 )
261 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
262 return entry
263
264
265 @router.get(
266 "/repos/{repo_id}/forks",
267 response_model=UserForksResponse,
268 operation_id="listRepoForks",
269 summary="List direct forks of a repository",
270 tags=["Repos"],
271 )
272 async def list_repo_forks(
273 repo_id: str,
274 db: AsyncSession = Depends(get_db),
275 claims: TokenClaims | None = Depends(optional_token),
276 ) -> UserForksResponse:
277 """List all direct forks of a repository.
278
279 Returns fork entries ordered newest-first, each including full metadata
280 for the fork repo plus source attribution so agents can traverse the fork
281 graph without a separate lookup.
282
283 No authentication required — fork lists are publicly visible.
284 Returns 404 when the source repo does not exist or is soft-deleted.
285 Returns an empty list when the repo has no forks.
286 """
287 source = await musehub_repository.get_repo(db, repo_id)
288 _guard_visibility(source, claims)
289 return await musehub_repository.list_repo_forks_flat(db, repo_id)
290
291
292 @router.get(
293 "/repos/{repo_id}/fork-network",
294 response_model=ForkNetworkResponse,
295 operation_id="getRepoForkNetwork",
296 summary="Get the full recursive fork network for a repository",
297 tags=["Repos"],
298 )
299 async def get_repo_fork_network(
300 repo_id: str,
301 db: AsyncSession = Depends(get_db),
302 claims: TokenClaims | None = Depends(optional_token),
303 ) -> ForkNetworkResponse:
304 """Return the complete fork network tree rooted at the given repository.
305
306 The response is a recursive tree:
307 - ``root`` represents the canonical upstream repo.
308 - ``root.children`` are its direct forks.
309 - Each child's ``children`` list its own forks, and so on.
310 - ``total_forks`` is the flat count of all fork nodes (excluding root).
311
312 Agents can use this to:
313 - Find the most-diverged fork before proposing a merge-back.
314 - Determine which downstream forks exist before a breaking change.
315 - Build a visualised fork graph in the UI.
316
317 No authentication required — fork networks are publicly visible.
318 Returns 404 when the source repo does not exist or is soft-deleted.
319 """
320 source = await musehub_repository.get_repo(db, repo_id)
321 _guard_visibility(source, claims)
322 return await musehub_repository.list_repo_forks(db, repo_id)
323
324
325 @router.get(
326 "/repos/{repo_id}",
327 response_model=RepoResponse,
328 operation_id="getRepo",
329 summary="Get remote repo metadata",
330 tags=["Repos"],
331 )
332 async def get_repo(
333 repo_id: str,
334 db: AsyncSession = Depends(get_db),
335 claims: TokenClaims | None = Depends(optional_token),
336 ) -> RepoResponse:
337 """Return metadata for the given repo. Returns 404 if not found."""
338 repo = await musehub_repository.get_repo(db, repo_id)
339 _guard_visibility(repo, claims)
340 assert repo is not None # _guard_visibility raises if None
341 return repo
342
343
344 @router.get(
345 "/repos/{repo_id}/branches",
346 response_model=BranchListResponse,
347 operation_id="listRepoBranches",
348 summary="List all branches in a remote repo",
349 tags=["Branches"],
350 )
351 async def list_branches(
352 repo_id: str,
353 db: AsyncSession = Depends(get_db),
354 claims: TokenClaims | None = Depends(optional_token),
355 ) -> BranchListResponse:
356 """Return all branch pointers for a repo, ordered by name."""
357 repo = await musehub_repository.get_repo(db, repo_id)
358 _guard_visibility(repo, claims)
359 branches = await musehub_repository.list_branches(db, repo_id)
360 return BranchListResponse(branches=branches)
361
362
363 @router.get(
364 "/repos/{repo_id}/branches/detail",
365 response_model=BranchDetailListResponse,
366 operation_id="listRepoBranchesDetail",
367 summary="List branches with ahead/behind counts and divergence scores",
368 tags=["Branches"],
369 )
370 async def list_branches_detail(
371 repo_id: str,
372 db: AsyncSession = Depends(get_db),
373 claims: TokenClaims | None = Depends(optional_token),
374 ) -> BranchDetailListResponse:
375 """Return branches enriched with ahead/behind counts vs the default branch.
376
377 Each branch includes:
378 - ``aheadCount``: commits on this branch not yet on the default branch
379 - ``behindCount``: commits on the default branch not yet merged here
380 - ``isDefault``: whether this is the repo's default branch
381 - ``divergence``: musical divergence scores (placeholder ``null`` until computable)
382
383 Used by the MuseHub branch list page to help musicians decide which branches
384 to merge or discard.
385 """
386 repo = await musehub_repository.get_repo(db, repo_id)
387 _guard_visibility(repo, claims)
388 return await musehub_repository.list_branches_with_detail(db, repo_id)
389
390
391
392
393 @router.post(
394 "/repos/{repo_id}/branches/{name}/reset",
395 response_model=BranchResetResponse,
396 operation_id="resetBranch",
397 summary="Force-move a branch head to any known commit",
398 tags=["Branches"],
399 )
400 async def reset_branch(
401 repo_id: str,
402 name: str,
403 body: BranchResetRequest,
404 db: AsyncSession = Depends(get_db),
405 claims: TokenClaims = Depends(require_scope("repo:write")),
406 ) -> BranchResetResponse:
407 """Point *name* at *commit_id*, regardless of history.
408
409 Used to undo a corrupt merge commit (bug #36) so the branch can be
410 re-merged cleanly. The target commit must already exist in the store.
411
412 Returns 404 if the repo, branch, or target commit is not found.
413 """
414 repo = await musehub_repository.get_repo(db, repo_id)
415 if repo is None:
416 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
417
418 branch_row = (await db.execute(
419 select(MusehubBranch).where(
420 MusehubBranch.repo_id == repo_id,
421 MusehubBranch.name == name,
422 )
423 )).scalar_one_or_none()
424 if branch_row is None:
425 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Branch '{name}' not found")
426
427 target = await db.get(MusehubCommit, body.commit_id)
428 if target is None:
429 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Commit {body.commit_id} not found")
430
431 previous = branch_row.head_commit_id or ""
432 branch_row.head_commit_id = body.commit_id
433 await db.commit()
434 return BranchResetResponse(branch=name, commit_id=body.commit_id, previous_commit_id=previous)
435
436
437 @router.get(
438 "/repos/{repo_id}/commits",
439 response_model=CommitListResponse,
440 operation_id="listRepoCommits",
441 summary="List commits in a remote repo (newest first)",
442 tags=["Commits"],
443 )
444 async def list_commits(
445 repo_id: str,
446 request: Request,
447 response: Response,
448 branch: str | None = Query(None, description="Filter by branch name"),
449 cursor: str | None = Query(None, description="Opaque cursor from a previous response"),
450 limit: int = Query(50, ge=1, le=200, description="Max commits to return"),
451 db: AsyncSession = Depends(get_db),
452 claims: TokenClaims | None = Depends(optional_token),
453 ) -> CommitListResponse:
454 """Return commits for a repo with cursor-based pagination (newest first).
455
456 Cursor-based keyset pagination anchors each page to a stable position in
457 the commit timestamp sequence. Pass ``nextCursor`` from a previous
458 response as ``?cursor=`` to advance. A null ``nextCursor`` means this
459 is the last page.
460 """
461 repo = await musehub_repository.get_repo(db, repo_id)
462 _guard_visibility(repo, claims)
463 result = await musehub_repository.list_commits(
464 db, repo_id, branch=branch, cursor=cursor, limit=limit
465 )
466 if result.next_cursor is not None:
467 response.headers["Link"] = build_cursor_link_header(
468 request, result.next_cursor, limit
469 )
470 return result
471
472
473 @router.get(
474 "/repos/{repo_id}/commits/{commit_id}",
475 response_model=CommitResponse,
476 operation_id="getRepoCommit",
477 summary="Get a single commit by ID",
478 tags=["Commits"],
479 )
480 async def get_commit(
481 repo_id: str,
482 commit_id: str,
483 db: AsyncSession = Depends(get_db),
484 claims: TokenClaims | None = Depends(optional_token),
485 ) -> CommitResponse:
486 """Return a single commit by its ID.
487
488 Returns 404 if the commit does not exist in this repo.
489 Raises 401 if the repo is private and the caller is unauthenticated.
490 """
491 repo = await musehub_repository.get_repo(db, repo_id)
492 _guard_visibility(repo, claims)
493 result = await musehub_repository.list_commits(db, repo_id, limit=500)
494 commit = next((c for c in result.commits if c.commit_id == commit_id), None)
495 if commit is None:
496 raise HTTPException(
497 status_code=status.HTTP_404_NOT_FOUND,
498 detail=f"Commit '{commit_id}' not found in repo '{repo_id}'",
499 )
500 return commit
501
502
503 @router.get(
504 "/repos/{repo_id}/commits/{commit_id}/diff-summary",
505 response_model=CommitDiffSummaryResponse,
506 operation_id="getCommitDiffSummary",
507 summary="Multi-dimensional diff summary between a commit and its parent",
508 tags=["Commits"],
509 )
510 async def get_commit_diff_summary(
511 repo_id: str,
512 commit_id: str,
513 db: AsyncSession = Depends(get_db),
514 claims: TokenClaims | None = Depends(optional_token),
515 ) -> CommitDiffSummaryResponse:
516 """Return a five-dimension musical diff summary between a commit and its parent.
517
518 Computes heuristic per-dimension change scores (harmonic, rhythmic, melodic,
519 structural, dynamic) from the commit message keywords and metadata. Scores
520 are in [0.0, 1.0] where 0 = no change and 1 = complete replacement.
521
522 Consumed by the commit detail page to render coloured dimension-change badges
523 that help musicians understand *what* musically changed in this push.
524
525 Returns:
526 CommitDiffSummaryResponse with per-dimension scores and overall mean.
527
528 Raises:
529 404: If the commit is not found in this repo.
530 """
531 repo = await musehub_repository.get_repo(db, repo_id)
532 _guard_visibility(repo, claims)
533 result = await musehub_repository.list_commits(db, repo_id, limit=500)
534 commit = next((c for c in result.commits if c.commit_id == commit_id), None)
535 if commit is None:
536 raise HTTPException(
537 status_code=status.HTTP_404_NOT_FOUND,
538 detail=f"Commit '{commit_id}' not found in repo '{repo_id}'",
539 )
540 parent_id = commit.parent_ids[0] if commit.parent_ids else None
541 parent = next((c for c in result.commits if c.commit_id == parent_id), None) if parent_id else None
542
543 dimensions = _compute_commit_diff_dimensions(commit, parent)
544 overall = sum(d.score for d in dimensions) / len(dimensions) if dimensions else 0.0
545 return CommitDiffSummaryResponse(
546 commit_id=commit_id,
547 parent_id=parent_id,
548 dimensions=dimensions,
549 overall_score=round(overall, 4),
550 )
551
552
553 def _dim_label_color(score: float) -> tuple[str, str]:
554 """Map a [0,1] score to a (label, CSS-class) pair for badge rendering."""
555 if score < 0.15:
556 return "none", "dim-none"
557 if score < 0.40:
558 return "low", "dim-low"
559 if score < 0.70:
560 return "medium", "dim-medium"
561 return "high", "dim-high"
562
563
564 _HARMONIC_KEYWORDS = frozenset(
565 ["key", "chord", "harmony", "harmonic", "tonal", "modulation", "progression", "pitch"]
566 )
567 _RHYTHMIC_KEYWORDS = frozenset(
568 ["bpm", "tempo", "beat", "rhythm", "rhythmic", "groove", "swing", "meter", "time"]
569 )
570 _MELODIC_KEYWORDS = frozenset(
571 ["melody", "melodic", "lead", "motif", "phrase", "contour", "scale", "mode"]
572 )
573 _STRUCTURAL_KEYWORDS = frozenset(
574 [
575 "section",
576 "structural",
577 "intro",
578 "verse",
579 "chorus",
580 "bridge",
581 "outro",
582 "form",
583 "arrangement",
584 "structure",
585 ]
586 )
587 _DYNAMIC_KEYWORDS = frozenset(
588 [
589 "dynamic",
590 "volume",
591 "velocity",
592 "loud",
593 "soft",
594 "crescendo",
595 "decrescendo",
596 "fade",
597 "mute",
598 "swell",
599 ]
600 )
601
602
603 def _keyword_score(message: str, keywords: frozenset[str]) -> float:
604 """Return a [0, 1] score based on keyword density in a commit message.
605
606 Presence of any keyword gives a base 0.35 score; each additional keyword
607 adds 0.15 up to a ceiling of 0.95. Root commits (empty parent) implicitly
608 score 1.0 on all dimensions since everything is new.
609 """
610 msg_lower = message.lower()
611 hits = sum(1 for kw in keywords if kw in msg_lower)
612 if hits == 0:
613 return 0.0
614 return min(0.35 + (hits - 1) * 0.15, 0.95)
615
616
617 def _compute_commit_diff_dimensions(
618 commit: CommitResponse,
619 parent: CommitResponse | None,
620 ) -> list[CommitDiffDimensionScore]:
621 """Derive five-dimension diff scores from commit message keyword analysis.
622
623 When ``parent`` is None the commit is a root commit — all dimensions score
624 1.0 because every musical element is being introduced for the first time.
625 """
626 DIMS: list[tuple[str, frozenset[str]]] = [
627 ("harmonic", _HARMONIC_KEYWORDS),
628 ("rhythmic", _RHYTHMIC_KEYWORDS),
629 ("melodic", _MELODIC_KEYWORDS),
630 ("structural", _STRUCTURAL_KEYWORDS),
631 ("dynamic", _DYNAMIC_KEYWORDS),
632 ]
633
634 results: list[CommitDiffDimensionScore] = []
635 for dim_name, keywords in DIMS:
636 if parent is None:
637 raw = 1.0
638 else:
639 raw = _keyword_score(commit.message, keywords)
640 label, color = _dim_label_color(raw)
641 results.append(
642 CommitDiffDimensionScore(
643 dimension=dim_name,
644 score=round(raw, 4),
645 label=label,
646 color=color,
647 )
648 )
649 return results
650
651
652
653
654 @router.get(
655 "/repos/{repo_id}/timeline",
656 response_model=TimelineResponse,
657 operation_id="getRepoTimeline",
658 summary="Chronological timeline of musical evolution",
659 tags=["Commits"],
660 )
661 async def get_timeline(
662 repo_id: str,
663 limit: int = Query(200, ge=1, le=500, description="Max commits to include in the timeline"),
664 db: AsyncSession = Depends(get_db),
665 claims: TokenClaims | None = Depends(optional_token),
666 ) -> TimelineResponse:
667 """Return a chronological timeline of musical evolution for a repo.
668
669 The response contains four parallel event streams, each independently
670 toggleable by the client:
671 - ``commits``: every pushed commit as a timeline marker (oldest-first)
672 - ``emotion``: deterministic emotion vectors (valence/energy/tension) per commit
673 - ``sections``: section-change events parsed from commit messages
674 - ``tracks``: track add/remove events parsed from commit messages
675
676 Content negotiation: the UI page at ``GET /{repo_id}/timeline``
677 fetches this endpoint for its layered visualisation. AI agents call this
678 endpoint directly to understand the creative arc of a project.
679 """
680 repo = await musehub_repository.get_repo(db, repo_id)
681 _guard_visibility(repo, claims)
682 return await musehub_repository.get_timeline_events(db, repo_id, limit=limit)
683
684 @router.get(
685 "/repos/{repo_id}/divergence",
686 response_model=DivergenceResponse,
687 operation_id="getRepoDivergence",
688 summary="Compute musical divergence between two branches",
689 tags=["Branches"],
690 )
691 async def get_divergence(
692 repo_id: str,
693 branch_a: str = Query(..., description="First branch name"),
694 branch_b: str = Query(..., description="Second branch name"),
695 db: AsyncSession = Depends(get_db),
696 claims: TokenClaims | None = Depends(optional_token),
697 ) -> DivergenceResponse:
698 """Return a five-dimension musical divergence report between two branches.
699
700 Computes a per-dimension Jaccard divergence score by comparing each
701 branch's commit history since their common ancestor. Dimensions are:
702 melodic, harmonic, rhythmic, structural, and dynamic.
703
704 The ``overallScore`` field is the mean of all five dimension scores,
705 expressed in [0.0, 1.0]. Multiply by 100 for a percentage display.
706
707 Content negotiation: this endpoint always returns JSON. The UI page at
708 ``GET /{repo_id}/divergence`` renders the radar chart.
709
710 Returns:
711 DivergenceResponse with per-dimension scores and overall score.
712
713 Raises:
714 404: If the repo is not found.
715 422: If either branch has no commits in this repo.
716 """
717 repo = await musehub_repository.get_repo(db, repo_id)
718 _guard_visibility(repo, claims)
719 try:
720 result = await musehub_divergence.compute_hub_divergence(
721 db,
722 repo_id=repo_id,
723 branch_a=branch_a,
724 branch_b=branch_b,
725 )
726 except ValueError as exc:
727 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
728
729 dimensions = [
730 DivergenceDimensionResponse(
731 dimension=d.dimension,
732 level=d.level.value,
733 score=d.score,
734 description=d.description,
735 branch_a_commits=d.branch_a_commits,
736 branch_b_commits=d.branch_b_commits,
737 )
738 for d in result.dimensions
739 ]
740
741 return DivergenceResponse(
742 repo_id=repo_id,
743 branch_a=branch_a,
744 branch_b=branch_b,
745 common_ancestor=result.common_ancestor,
746 dimensions=dimensions,
747 overall_score=result.overall_score,
748 )
749
750
751 @router.get(
752 "/repos/{repo_id}/credits",
753 response_model=CreditsResponse,
754 operation_id="getRepoCredits",
755 summary="Get aggregated contributor credits for a repo",
756 tags=["Repos"],
757 )
758 async def get_credits(
759 repo_id: str,
760 sort: str = Query(
761 "count",
762 pattern="^(count|recency|alpha)$",
763 description="Sort order: 'count' (most prolific), 'recency' (most recent), 'alpha' (A–Z)",
764 ),
765 db: AsyncSession = Depends(get_db),
766 claims: TokenClaims | None = Depends(optional_token),
767 ) -> CreditsResponse:
768 """Return dynamic contributor credits aggregated from all commits in a repo.
769
770 Analogous to album liner notes: every contributor is listed with their
771 session count, inferred contribution types (composer, arranger, producer,
772 etc.), and activity window (first and last commit timestamps).
773
774 Content negotiation: when the request ``Accept`` header includes
775 ``application/ld+json``, clients should request the ``/credits`` endpoint
776 directly — the JSON body is schema.org-compatible and can be wrapped in
777 JSON-LD by the consumer. This endpoint always returns ``application/json``.
778
779 Returns 404 if the repo does not exist.
780 Returns an empty ``contributors`` list when no commits have been pushed yet.
781 """
782 repo = await musehub_repository.get_repo(db, repo_id)
783 _guard_visibility(repo, claims)
784 return await musehub_credits.aggregate_credits(db, repo_id, sort=sort)
785
786
787 @router.get(
788 "/repos/{repo_id}/context/{ref}",
789 response_model=MuseHubContextResponse,
790 operation_id="getRepoContextByRef",
791 summary="Get musical context document for a commit",
792 tags=["Commits"],
793 )
794 async def get_context(
795 repo_id: str,
796 ref: str,
797 db: AsyncSession = Depends(get_db),
798 claims: TokenClaims | None = Depends(optional_token),
799 ) -> MuseHubContextResponse:
800 """Return a structured musical context document for the given commit ref.
801
802 The context document is the same information the AI agent receives when
803 generating music for this repo at this commit — making it human-inspectable
804 for debugging and transparency.
805
806 Raises 404 if either the repo or the commit does not exist.
807 """
808 repo = await musehub_repository.get_repo(db, repo_id)
809 _guard_visibility(repo, claims)
810 context = await musehub_repository.get_context_for_commit(db, repo_id, ref)
811 if context is None:
812 raise HTTPException(
813 status_code=status.HTTP_404_NOT_FOUND,
814 detail=f"Commit {ref!r} not found in repo",
815 )
816 return context
817
818
819 @router.get(
820 "/repos/{repo_id}/context",
821 operation_id="getAgentContext",
822 summary="Get complete agent context for a repo ref",
823 tags=["Repos"],
824 responses={
825 200: {"description": "Agent context document (JSON or YAML)"},
826 404: {"description": "Repo not found or ref has no commits"},
827 },
828 )
829 async def get_agent_context(
830 repo_id: str,
831 ref: str = Query(
832 "HEAD",
833 description="Branch name or commit ID to build context for. 'HEAD' resolves to the latest commit.",
834 ),
835 depth: ContextDepth = Query(
836 ContextDepth.standard,
837 description="Depth level: 'brief' (~2K tokens), 'standard' (~8K tokens), 'verbose' (uncapped)",
838 ),
839 format: ContextFormat = Query(
840 ContextFormat.json,
841 description="Response format: 'json' or 'yaml'",
842 ),
843 db: AsyncSession = Depends(get_db),
844 claims: TokenClaims | None = Depends(optional_token),
845 ) -> Response:
846 """Return a complete musical context briefing for AI agent consumption.
847
848 This endpoint is the canonical entry point for agents starting a composition
849 session. It aggregates musical state, commit history, per-dimension analysis,
850 open proposals, open issues, and actionable suggestions into a single document.
851
852 Use ``?depth=brief`` to fit the response in a small context window (~2 K tokens).
853 Use ``?depth=verbose`` for full bodies and extended history.
854 Use ``?format=yaml`` for human-readable output (e.g. in agent logs).
855 """
856 repo = await musehub_repository.get_repo(db, repo_id)
857 _guard_visibility(repo, claims)
858 context = await musehub_context.build_agent_context(
859 db,
860 repo_id=repo_id,
861 ref=ref,
862 depth=depth,
863 )
864 if context is None:
865 raise HTTPException(
866 status_code=status.HTTP_404_NOT_FOUND,
867 detail="Repo not found or ref has no commits",
868 )
869
870 if format == ContextFormat.yaml:
871 payload = context.model_dump(by_alias=True)
872 yaml_text: str = yaml.dump(payload, allow_unicode=True, sort_keys=False)
873 return Response(content=yaml_text, media_type="application/x-yaml")
874
875 return Response(
876 content=context.model_dump_json(by_alias=True),
877 media_type="application/json",
878 )
879
880
881 @router.get(
882 "/repos/{repo_id}/dag",
883 response_model=DagGraphResponse,
884 operation_id="getCommitDag",
885 summary="Get the full commit DAG for a repo",
886 tags=["Commits"],
887 )
888 async def get_commit_dag(
889 repo_id: str,
890 db: AsyncSession = Depends(get_db),
891 claims: TokenClaims | None = Depends(optional_token),
892 ) -> DagGraphResponse:
893 """Return the full commit history as a topologically sorted directed acyclic graph.
894
895 Nodes are ordered oldest→newest (Kahn's topological sort). Edges express
896 child→parent relationships (``source`` = child commit, ``target`` = parent
897 commit). This endpoint is the data source for the interactive DAG graph UI
898 at ``GET /{repo_id}/graph``.
899
900 Content negotiation: always returns JSON. The UI page fetches this endpoint
901 with the stored MSign token and renders it client-side with an SVG-based renderer.
902
903 Performance: all commits are fetched (no limit) to ensure the graph is
904 complete. For repos with 100+ commits the response may be several KB; the
905 client-side renderer virtualises visible nodes.
906 """
907 repo = await musehub_repository.get_repo(db, repo_id)
908 _guard_visibility(repo, claims)
909 return await musehub_repository.list_commits_dag(db, repo_id)
910
911
912
913 @router.post(
914 "/repos/{repo_id}/sessions",
915 response_model=SessionResponse,
916 status_code=status.HTTP_201_CREATED,
917 operation_id="createSession",
918 summary="Create a recording session entry",
919 tags=["Sessions"],
920 )
921 async def create_session(
922 repo_id: str,
923 body: SessionCreate,
924 db: AsyncSession = Depends(get_db),
925 claims: TokenClaims = Depends(require_scope("repo:write")),
926 ) -> SessionResponse:
927 """Register a new recording session on the Hub.
928
929 Called by the CLI on ``muse session start``. Returns the persisted session
930 including its server-assigned ``session_id``.
931 """
932 repo = await musehub_repository.get_repo(db, repo_id)
933 if repo is None:
934 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
935
936 session_resp = await musehub_repository.create_session(
937 db,
938 repo_id,
939 started_at=body.started_at,
940 participants=body.participants,
941 intent=body.intent,
942 location=body.location,
943 author_identity_id=claims.identity_id,
944 )
945 await db.commit()
946 return session_resp
947
948
949 @router.get(
950 "/repos/{repo_id}/sessions",
951 response_model=SessionListResponse,
952 operation_id="listSessions",
953 summary="List recording sessions for a repo (newest first)",
954 tags=["Sessions"],
955 )
956 async def list_sessions(
957 repo_id: str,
958 limit: int = Query(50, ge=1, le=200, description="Max sessions to return"),
959 cursor: str | None = Query(None, description="Cursor from previous response nextCursor"),
960 db: AsyncSession = Depends(get_db),
961 claims: TokenClaims | None = Depends(optional_token),
962 ) -> SessionListResponse:
963 """Return sessions for a repo, sorted newest-first by started_at.
964
965 Returns 404 if the repo does not exist. Cursor-paginated: pass the
966 ``nextCursor`` value from a previous response as ``?cursor=`` to retrieve
967 the next page. ``nextCursor`` is ``null`` on the last page.
968 """
969 repo = await musehub_repository.get_repo(db, repo_id)
970 _guard_visibility(repo, claims)
971 sessions, total, next_cursor = await musehub_repository.list_sessions(
972 db, repo_id, limit=limit, cursor=cursor
973 )
974 return SessionListResponse(sessions=sessions, total=total, next_cursor=next_cursor)
975
976
977 @router.get(
978 "/repos/{repo_id}/sessions/{session_id}",
979 response_model=SessionResponse,
980 operation_id="getSession",
981 summary="Get a single recording session by ID",
982 tags=["Sessions"],
983 )
984 async def get_session(
985 repo_id: str,
986 session_id: str,
987 db: AsyncSession = Depends(get_db),
988 claims: TokenClaims | None = Depends(optional_token),
989 ) -> SessionResponse:
990 """Return a single session record.
991
992 Returns 404 if the repo or session does not exist. The ``session_id``
993 must be an exact match — the hub does not support prefix lookups.
994 """
995 repo = await musehub_repository.get_repo(db, repo_id)
996 _guard_visibility(repo, claims)
997 session = await musehub_repository.get_session(db, repo_id, session_id)
998 if session is None:
999 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
1000 return session
1001
1002
1003 @router.post(
1004 "/repos/{repo_id}/sessions/{session_id}/stop",
1005 response_model=SessionResponse,
1006 operation_id="stopSession",
1007 summary="Mark a recording session as ended",
1008 tags=["Sessions"],
1009 )
1010 async def stop_session(
1011 repo_id: str,
1012 session_id: str,
1013 body: SessionStop,
1014 db: AsyncSession = Depends(get_db),
1015 _: TokenClaims = Depends(require_scope("repo:write")),
1016 ) -> SessionResponse:
1017 """Close an active session and record its end time.
1018
1019 Called by the CLI on ``muse session stop``. Idempotent — calling stop on
1020 an already-stopped session updates ``ended_at`` and returns the session.
1021 """
1022 repo = await musehub_repository.get_repo(db, repo_id)
1023 if repo is None:
1024 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1025
1026 sess = await musehub_repository.stop_session(db, repo_id, session_id, body.ended_at)
1027 if sess is None:
1028 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
1029 await db.commit()
1030 return sess
1031
1032
1033 @router.get(
1034 "/repos/{repo_id}/stats",
1035 response_model=RepoStatsResponse,
1036 summary="Aggregated counts for the repo home page stats bar",
1037 )
1038 async def get_repo_stats(
1039 repo_id: str,
1040 db: AsyncSession = Depends(get_db),
1041 claims: TokenClaims | None = Depends(optional_token),
1042 ) -> RepoStatsResponse:
1043 """Return aggregated statistics for a repo: commit count, branch count, release count.
1044
1045 This lightweight endpoint powers the stats bar on the repo home page and
1046 the JSON content-negotiation response from ``GET /{owner}/{slug}``.
1047 All counts are 0 when the repo has no data yet.
1048
1049 Returns 404 if the repo does not exist.
1050 Returns 401 if the repo is private and the caller is unauthenticated.
1051 """
1052 repo = await musehub_repository.get_repo(db, repo_id)
1053 _guard_visibility(repo, claims)
1054
1055 branches = await musehub_repository.list_branches(db, repo_id)
1056 commits_result = await musehub_repository.list_commits(db, repo_id, limit=1)
1057 releases_result = await musehub_releases.list_releases(db, repo_id)
1058
1059 return RepoStatsResponse(
1060 commit_count=commits_result.total,
1061 branch_count=len(branches),
1062 release_count=releases_result.total,
1063 )
1064
1065
1066 @router.get(
1067 "/repos/{repo_id}/groove-check",
1068 response_model=GrooveCheckResponse,
1069 summary="Get rhythmic consistency analysis for a repo commit window",
1070 )
1071 async def get_groove_check(
1072 repo_id: str,
1073 threshold: float = Query(
1074 0.1,
1075 ge=0.01,
1076 le=1.0,
1077 description="Drift threshold in beats — commits exceeding this are flagged WARN or FAIL",
1078 ),
1079 limit: int = Query(10, ge=1, le=50, description="Maximum number of commits to analyse"),
1080 track: str | None = Query(None, description="Restrict analysis to a named instrument track"),
1081 section: str | None = Query(None, description="Restrict analysis to a named musical section"),
1082 db: AsyncSession = Depends(get_db),
1083 _: TokenClaims = Depends(require_valid_token),
1084 ) -> GrooveCheckResponse:
1085 """Return rhythmic consistency metrics for the most recent commits in a repo."""
1086 repo = await musehub_repository.get_repo(db, repo_id)
1087 if repo is None:
1088 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1089
1090 commit_range = f"HEAD~{limit}..HEAD"
1091 return GrooveCheckResponse(
1092 commit_range=commit_range,
1093 threshold=threshold,
1094 total_commits=0,
1095 flagged_commits=0,
1096 worst_commit="",
1097 entries=[],
1098 )
1099
1100
1101
1102 @router.get(
1103 "/repos/{repo_id}/compare",
1104 response_model=CompareResponse,
1105 operation_id="compareRefs",
1106 summary="Compare two refs — multi-dimensional divergence",
1107 tags=["Commits"],
1108 )
1109 async def compare_refs(
1110 repo_id: str,
1111 base: str = Query(..., description="Base ref (branch name or commit SHA)"),
1112 head: str = Query(..., description="Head ref (branch name or commit SHA)"),
1113 db: AsyncSession = Depends(get_db),
1114 claims: TokenClaims | None = Depends(optional_token),
1115 ) -> CompareResponse:
1116 """Return a multi-dimensional comparison between two refs.
1117
1118 Computes per-dimension divergence scores and lists commits unique to the head ref.
1119
1120 ``base`` and ``head`` are resolved as branch names first. If no commits
1121 are found on a branch with that exact name, the ref is treated as a commit
1122 SHA prefix and all commits for the repo are scanned.
1123
1124 Returns:
1125 CompareResponse containing divergence dimensions, commit list, and
1126 emotion diff.
1127
1128 Raises:
1129 404: Repo not found.
1130 422: Base or head ref resolves to zero commits.
1131 """
1132 repo = await musehub_repository.get_repo(db, repo_id)
1133 _guard_visibility(repo, claims)
1134
1135 # ── Divergence (reuse existing engine; works on branch names) ────────────
1136 try:
1137 div_result = await musehub_divergence.compute_hub_divergence(
1138 db,
1139 repo_id=repo_id,
1140 branch_a=base,
1141 branch_b=head,
1142 )
1143 except ValueError as exc:
1144 raise HTTPException(
1145 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
1146 )
1147
1148 dimensions = [
1149 DivergenceDimensionResponse(
1150 dimension=d.dimension,
1151 level=d.level.value,
1152 score=d.score,
1153 description=d.description,
1154 branch_a_commits=d.branch_a_commits,
1155 branch_b_commits=d.branch_b_commits,
1156 )
1157 for d in div_result.dimensions
1158 ]
1159
1160 # ── Commits unique to head ────────────────────────────────────────────────
1161 base_result = await musehub_repository.list_commits(db, repo_id, branch=base, limit=500)
1162 head_result = await musehub_repository.list_commits(db, repo_id, branch=head, limit=500)
1163 base_ids = {c.commit_id for c in base_result.commits}
1164 head_only = [c for c in head_result.commits if c.commit_id not in base_ids]
1165
1166 # ── Proposal creation URL ──────────────────────────────────────────────────────
1167 # repo is guaranteed non-None here — _guard_visibility raised 404 otherwise.
1168 assert repo is not None
1169 create_proposal_url = (
1170 f"/{repo.owner}/{repo.slug}/pulls/new"
1171 f"?base={base}&head={head}"
1172 )
1173
1174 # ── Emotion diff — derive from divergence score as a proxy ───────────────
1175 # In the absence of per-commit emotion vectors we derive a lightweight
1176 # scalar representation from the overall divergence score so the field
1177 # is always present and in-range.
1178 _base_energy = max(0.0, 0.5 - div_result.overall_score * 0.5)
1179 _head_energy = min(1.0, 0.5 + div_result.overall_score * 0.5)
1180 _base_valence = max(0.0, 0.5 - div_result.overall_score * 0.3)
1181 _head_valence = min(1.0, 0.5 + div_result.overall_score * 0.3)
1182 emotion_diff = EmotionDiff(
1183 base_energy=_base_energy,
1184 head_energy=_head_energy,
1185 base_valence=_base_valence,
1186 head_valence=_head_valence,
1187 energy_delta=max(-1.0, min(1.0, _head_energy - _base_energy)),
1188 valence_delta=max(-1.0, min(1.0, _head_valence - _base_valence)),
1189 tension_delta=max(-1.0, min(1.0, div_result.overall_score * 0.4)),
1190 darkness_delta=max(-1.0, min(1.0, -div_result.overall_score * 0.2)),
1191 )
1192
1193 return CompareResponse(
1194 repo_id=repo_id,
1195 base_ref=base,
1196 head_ref=head,
1197 common_ancestor=div_result.common_ancestor,
1198 dimensions=dimensions,
1199 overall_score=div_result.overall_score,
1200 commits=head_only,
1201 create_proposal_url=create_proposal_url,
1202 emotion_diff=emotion_diff,
1203 )
1204
1205
1206
1207 # ── Owner guard — stricter than admin: only the repo owner passes ─────────────
1208
1209
1210 def _guard_owner(repo: RepoResponse | None, caller_user_id: str) -> None:
1211 """Raise 404 if the repo does not exist; raise 403 if the caller is not the owner.
1212
1213 Transfer and deletion are owner-only operations — admin collaborators are
1214 explicitly excluded. Accepting any collaborator here would allow a
1215 compromised collaborator account to destroy or hijack repos.
1216 """
1217 if repo is None:
1218 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1219 if repo.owner != caller_user_id:
1220 raise HTTPException(
1221 status_code=status.HTTP_403_FORBIDDEN,
1222 detail="Only the repo owner may perform this action",
1223 )
1224
1225
1226 # ── Repo DELETE (soft-delete) ─────────────────────────────────────────────────
1227
1228
1229 @router.delete(
1230 "/repos/{repo_id}",
1231 status_code=status.HTTP_204_NO_CONTENT,
1232 operation_id="deleteRepo",
1233 summary="Soft-delete a repo (owner only)",
1234 tags=["Repos"],
1235 )
1236 async def delete_repo(
1237 repo_id: str,
1238 db: AsyncSession = Depends(get_db),
1239 claims: TokenClaims = Depends(require_scope("repo:write")),
1240 ) -> Response:
1241 """Soft-delete a MuseHub repo.
1242
1243 Marks the repo as deleted by recording a ``deleted_at`` timestamp; all
1244 data is retained in the database for audit purposes. Subsequent reads
1245 (GET /repos/{repo_id}, branch/commit queries, etc.) will return 404.
1246
1247 Only the repo owner may delete a repo — admin collaborators are not
1248 permitted.
1249
1250 Returns:
1251 204 No Content on success.
1252
1253 Raises:
1254 401: Missing or invalid MSign token.
1255 403: Caller is not the repo owner.
1256 404: Repo not found or already deleted.
1257 """
1258 caller_user_id: str = claims.handle
1259 repo = await musehub_repository.get_repo(db, repo_id)
1260 _guard_owner(repo, caller_user_id)
1261 deleted = await musehub_repository.delete_repo(db, repo_id)
1262 if not deleted:
1263 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1264 await db.commit()
1265 logger.info("✅ Repo %s soft-deleted by user %s", repo_id, caller_user_id)
1266 return Response(status_code=status.HTTP_204_NO_CONTENT)
1267
1268
1269 # ── Repo ownership transfer ───────────────────────────────────────────────────
1270
1271
1272 @router.post(
1273 "/repos/{repo_id}/transfer",
1274 response_model=RepoResponse,
1275 operation_id="transferRepoOwnership",
1276 summary="Transfer repo ownership to another user (owner only)",
1277 tags=["Repos"],
1278 )
1279 async def transfer_repo_ownership(
1280 repo_id: str,
1281 body: TransferOwnershipRequest,
1282 db: AsyncSession = Depends(get_db),
1283 claims: TokenClaims = Depends(require_scope("repo:write")),
1284 ) -> RepoResponse:
1285 """Transfer ownership of a MuseHub repo to another user.
1286
1287 Updates ``owner_user_id`` on the repo record. After a successful transfer
1288 the calling user loses owner privileges; the new owner gains them
1289 immediately. The public ``owner`` username slug is NOT automatically
1290 changed — the new owner may update it via the settings endpoint.
1291
1292 Only the current repo owner may initiate a transfer — admin collaborators
1293 are not permitted.
1294
1295 Returns:
1296 The updated RepoResponse with the new ``ownerUserId``.
1297
1298 Raises:
1299 401: Missing or invalid MSign token.
1300 403: Caller is not the repo owner.
1301 404: Repo not found or already deleted.
1302 """
1303 caller_user_id: str = claims.handle
1304 repo = await musehub_repository.get_repo(db, repo_id)
1305 _guard_owner(repo, caller_user_id)
1306 updated = await musehub_repository.transfer_repo_ownership(
1307 db, repo_id, body.new_owner_user_id
1308 )
1309 if updated is None:
1310 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1311 await db.commit()
1312 logger.info(
1313 "✅ Repo %s ownership transferred from %s to %s",
1314 repo_id,
1315 caller_user_id,
1316 body.new_owner_user_id,
1317 )
1318 return updated
1319
1320
1321 # ── Repo settings (GET + PATCH) — declared before catch-all ──────────────────
1322
1323
1324 async def _guard_admin(
1325 repo: RepoResponse | None, caller_user_id: str, db: AsyncSession
1326 ) -> None:
1327 """Raise 404 if repo is absent; raise 403 if caller lacks admin permission.
1328
1329 Admin permission is granted when the caller is the repo owner OR when they
1330 have an accepted collaborator row with ``permission='admin'``.
1331
1332 Args:
1333 repo: The repo metadata (None triggers 404).
1334 caller_user_id: MSign handle of the authenticated caller.
1335 db: Active async DB session for the collaborator lookup.
1336 """
1337 if repo is None:
1338 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1339 if repo.owner == caller_user_id:
1340 return
1341 stmt = (
1342 select(collab_models.MusehubCollaborator)
1343 .where(
1344 collab_models.MusehubCollaborator.repo_id == repo.repo_id,
1345 collab_models.MusehubCollaborator.identity_handle == caller_user_id,
1346 collab_models.MusehubCollaborator.permission == "admin",
1347 collab_models.MusehubCollaborator.accepted_at.is_not(None),
1348 )
1349 )
1350 row = (await db.execute(stmt)).scalar_one_or_none()
1351 if row is None:
1352 raise HTTPException(
1353 status_code=status.HTTP_403_FORBIDDEN,
1354 detail="Admin permission required to access repo settings",
1355 )
1356
1357
1358 @router.get(
1359 "/repos/{repo_id}/settings",
1360 response_model=RepoSettingsResponse,
1361 operation_id="getRepoSettings",
1362 summary="Get mutable settings for a repo",
1363 tags=["Repos"],
1364 )
1365 async def get_repo_settings(
1366 repo_id: str,
1367 db: AsyncSession = Depends(get_db),
1368 claims: TokenClaims = Depends(require_valid_token),
1369 ) -> RepoSettingsResponse:
1370 """Return the mutable settings for a repo.
1371
1372 Only the repo owner or an admin collaborator may call this endpoint.
1373 Returns 403 when the caller lacks admin permission; 404 when the repo
1374 does not exist.
1375
1376 Settings combine dedicated-column values (name, description, visibility,
1377 topics) with feature flags stored in the ``settings`` JSON blob
1378 (has_issues, allow_merge_commit, etc.). Missing flags are back-filled
1379 with canonical defaults on first read so every response is fully
1380 populated regardless of when the repo was created.
1381
1382 Agent use case: read before updating project metadata or configuring the
1383 Proposal merge strategy.
1384 """
1385 caller_user_id: str = claims.handle
1386 repo = await musehub_repository.get_repo(db, repo_id)
1387 await _guard_admin(repo, caller_user_id, db)
1388 settings = await musehub_repository.get_repo_settings(db, repo_id)
1389 if settings is None:
1390 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1391 return settings
1392
1393
1394 @router.patch(
1395 "/repos/{repo_id}/settings",
1396 response_model=RepoSettingsResponse,
1397 operation_id="patchRepoSettings",
1398 summary="Update mutable settings for a repo",
1399 tags=["Repos"],
1400 )
1401 async def patch_repo_settings(
1402 repo_id: str,
1403 body: RepoSettingsPatch,
1404 db: AsyncSession = Depends(get_db),
1405 claims: TokenClaims = Depends(require_scope("repo:write")),
1406 ) -> RepoSettingsResponse:
1407 """Partially update mutable settings for a repo.
1408
1409 Only the repo owner or an admin collaborator may call this endpoint.
1410 All request body fields are optional — only non-null values are written.
1411
1412 ``visibility`` must be ``'public'`` or ``'private'`` when supplied.
1413 ``topics`` replaces the full tag list when provided.
1414
1415 Returns 403 when the caller lacks admin permission; 404 when the repo
1416 does not exist. On success, the full updated settings object is returned
1417 so callers do not need a follow-up GET.
1418
1419 Agent use case: update visibility, merge strategy, or homepage URL
1420 atomically without touching other settings fields.
1421 """
1422 caller_user_id: str = claims.handle
1423 repo = await musehub_repository.get_repo(db, repo_id)
1424 await _guard_admin(repo, caller_user_id, db)
1425 updated = await musehub_repository.update_repo_settings(db, repo_id, body)
1426 if updated is None:
1427 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1428 await db.commit()
1429 return updated
1430
1431
1432 # ── Collaborator access-check — before owner/slug catch-all ──────────────────
1433
1434
1435 @router.get(
1436 "/repos/{repo_id}/collaborators/{username}/permission",
1437 response_model=CollaboratorAccessResponse,
1438 operation_id="checkCollaboratorAccess",
1439 summary="Check a user's effective permission level on a repo",
1440 tags=["Repos"],
1441 )
1442 async def check_collaborator_access(
1443 repo_id: str,
1444 username: str,
1445 db: AsyncSession = Depends(get_db),
1446 _: TokenClaims = Depends(require_valid_token),
1447 ) -> CollaboratorAccessResponse:
1448 """Return the effective permission level for *username* on *repo_id*.
1449
1450 The repo owner's effective permission is always ``"owner"`` with
1451 ``accepted_at: null`` (ownership is immediate, not via invitation).
1452
1453 If *username* is found in the accepted collaborator list, the row's
1454 ``permission`` and ``accepted_at`` values are returned.
1455
1456 Raises 404 when *username* is neither the owner nor an accepted collaborator,
1457 so callers can distinguish a known absence from a positive grant.
1458
1459 Auth: requires a valid MSign token.
1460 """
1461 repo = await musehub_repository.get_repo(db, repo_id)
1462 if repo is None:
1463 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1464
1465 # Owner case: always returns "owner" permission immediately.
1466 if username == repo.owner_user_id:
1467 return CollaboratorAccessResponse(
1468 username=username,
1469 permission="owner",
1470 accepted_at=None,
1471 )
1472
1473 stmt = (
1474 select(collab_models.MusehubCollaborator)
1475 .where(
1476 collab_models.MusehubCollaborator.repo_id == repo_id,
1477 collab_models.MusehubCollaborator.identity_handle == username,
1478 )
1479 )
1480 collab = (await db.execute(stmt)).scalar_one_or_none()
1481
1482 if collab is None:
1483 raise HTTPException(
1484 status_code=status.HTTP_404_NOT_FOUND,
1485 detail=f"{username} is not a collaborator on this repo",
1486 )
1487
1488 return CollaboratorAccessResponse(
1489 username=username,
1490 permission=str(collab.permission),
1491 accepted_at=collab.accepted_at,
1492 )
1493
1494
1495 # ── Symbol index rebuild ──────────────────────────────────────────────────────
1496
1497 @router.post(
1498 "/repos/{repo_id}/symbol-index/rebuild",
1499 status_code=status.HTTP_202_ACCEPTED,
1500 summary="Rebuild symbol index for a repo",
1501 tags=["Repos"],
1502 )
1503 async def rebuild_symbol_index(
1504 repo_id: str,
1505 db: AsyncSession = Depends(get_db),
1506 claims: TokenClaims = Depends(require_scope("repo:write")),
1507 ) -> StrDict:
1508 """Trigger an immediate symbol-index rebuild for *repo_id*.
1509
1510 Requires authentication. Returns 404 if the repo has no commits.
1511 """
1512 from musehub.services.musehub_symbol_indexer import build_symbol_index # noqa: PLC0415
1513 from musehub.services.musehub_intel_providers import persist_intel_results # noqa: PLC0415
1514 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef # noqa: PLC0415
1515
1516 result = await db.execute(
1517 select(MusehubCommit)
1518 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1519 .where(MusehubCommitRef.repo_id == repo_id)
1520 .order_by(MusehubCommit.timestamp.desc())
1521 .limit(1)
1522 )
1523 head = result.scalars().first()
1524 if head is None:
1525 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No commits found for repo")
1526
1527 results = await build_symbol_index(db, repo_id, head.commit_id)
1528 if not results:
1529 await db.commit()
1530 return {"status": "skipped", "reason": "no symbol ops found"}
1531 await persist_intel_results(db, repo_id, head.commit_id, results)
1532 await db.commit()
1533 return {"status": "ok", "repo_id": repo_id, "head": head.commit_id}
1534
1535
1536 # ── Owner/slug resolver — declared LAST to avoid shadowing /repos/... routes ──
1537
1538
1539 @router.get(
1540 "/{owner}/{repo_slug}",
1541 response_model=RepoResponse,
1542 operation_id="getRepoByOwnerSlug",
1543 summary="Get repo metadata by owner/slug",
1544 tags=["Repos"],
1545 )
1546 async def get_repo_by_owner_slug(
1547 owner: SlugParam,
1548 repo_slug: SlugParam,
1549 db: AsyncSession = Depends(get_db),
1550 claims: TokenClaims | None = Depends(optional_token),
1551 ) -> RepoResponse:
1552 """Return metadata for the repo identified by its canonical /{owner}/{slug} path.
1553
1554 Declared last so that all /repos/... fixed-prefix routes take precedence.
1555 Returns 404 for unknown owner/slug combinations.
1556 """
1557 repo = await musehub_repository.get_repo_by_owner_slug(db, owner, repo_slug)
1558 if repo is None:
1559 raise HTTPException(
1560 status_code=status.HTTP_404_NOT_FOUND,
1561 detail=f"Repo '{owner}/{repo_slug}' not found",
1562 )
1563 _guard_visibility(repo, claims)
1564 return repo
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 99 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 120 days ago