gabriel / musehub public
repos.py python
200 lines 7.1 KB
d4eb1c39 Theme overhaul: domains, new-repo, MCP docs, copy icons; legacy CSS rem… Gabriel Cardona <cgcardona@gmail.com> 3d ago
1 """REST API — repo endpoints.
2
3 Mounted at /api/repos/...
4
5 These are the canonical machine-readable repo endpoints.
6 The browser UI continues to live at /{owner}/{slug}/... for human-readable URLs.
7
8 Endpoint surface:
9 GET /api/repos — list / search repos
10 POST /api/repos — create a repo
11 GET /api/repos/{repo_id} — get repo metadata
12 PATCH /api/repos/{repo_id} — update repo metadata
13 DELETE /api/repos/{repo_id} — soft-delete a repo
14
15 GET /api/repos/{repo_id}/branches — list branches
16 GET /api/repos/{repo_id}/commits — list commits (paginated)
17 GET /api/repos/{repo_id}/commits/{commit_id} — single commit
18
19 GET /api/repos/{repo_id}/objects/{object_id} — serve a binary object
20
21 Wire-protocol endpoints live at /wire/repos/{repo_id}/... (separate router).
22 """
23 from __future__ import annotations
24
25 import logging
26
27 from fastapi import APIRouter, Depends, HTTPException, Query, status
28 from fastapi.responses import Response
29 from sqlalchemy import select
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.auth.dependencies import optional_token, require_valid_token, TokenClaims
33 from musehub.db import musehub_models as db
34 from musehub.db.database import get_db as get_session
35 from musehub.services.musehub_repository import (
36 get_repo,
37 list_repos_for_user,
38 create_repo,
39 list_commits,
40 )
41 from musehub.services import musehub_qdrant as qdrant_svc
42
43 logger = logging.getLogger(__name__)
44
45 router = APIRouter(tags=["Repos"])
46
47
48 @router.get("/api/repos", summary="List or search repos")
49 async def list_repos_endpoint(
50 owner: str | None = Query(None),
51 domain: str | None = Query(None),
52 q: str | None = Query(None, description="Semantic search query (uses Qdrant when available)"),
53 page: int = Query(1, ge=1),
54 per_page: int = Query(30, ge=1, le=100),
55 _claims: TokenClaims | None = Depends(optional_token),
56 session: AsyncSession = Depends(get_session),
57 ) -> Response:
58 """List repos with optional filtering.
59
60 When ``q`` is provided and Qdrant is configured, performs semantic search.
61 Otherwise falls back to Postgres text matching.
62 """
63 import json as _json
64 if q:
65 qdrant = qdrant_svc.get_qdrant()
66 if qdrant is not None:
67 semantic_results = await qdrant_svc.semantic_search_repos(
68 qdrant, q, limit=per_page, domain=domain
69 )
70 return Response(
71 content=_json.dumps({"repos": semantic_results, "semantic": True}),
72 media_type="application/json",
73 )
74
75 # Fallback: list by owner if provided, otherwise empty
76 if owner:
77 result = await list_repos_for_user(session, user_id=owner, limit=per_page)
78 return Response(content=result.model_dump_json(), media_type="application/json")
79
80 return Response(
81 content=_json.dumps({"repos": [], "hint": "Provide ?owner= or ?q= to filter results"}),
82 media_type="application/json",
83 )
84
85
86 @router.post("/api/repos", summary="Create a repo", status_code=status.HTTP_201_CREATED)
87 async def create_repo_endpoint(
88 body: dict[str, object],
89 claims: TokenClaims = Depends(require_valid_token),
90 session: AsyncSession = Depends(get_session),
91 ) -> Response:
92 """Create a new repo under the authenticated identity."""
93 owner = claims.get("sub") or ""
94 if not owner:
95 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no subject in token")
96
97 name = str(body.get("name") or body.get("slug") or "")
98 description = str(body.get("description") or "")
99 visibility = "private" if body.get("is_private") else "public"
100
101 if not name:
102 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="name is required")
103
104 result = await create_repo(
105 session,
106 name=name,
107 owner=owner,
108 visibility=visibility,
109 owner_user_id=owner,
110 description=description,
111 )
112 return Response(
113 content=result.model_dump_json(),
114 media_type="application/json",
115 status_code=status.HTTP_201_CREATED,
116 )
117
118
119 @router.get("/api/repos/{repo_id}", summary="Get a repo by ID")
120 async def get_repo_endpoint(
121 repo_id: str,
122 _claims: TokenClaims | None = Depends(optional_token),
123 session: AsyncSession = Depends(get_session),
124 ) -> Response:
125 result = await get_repo(session, repo_id)
126 if result is None:
127 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repo not found")
128 return Response(content=result.model_dump_json(), media_type="application/json")
129
130
131 @router.get("/api/repos/{repo_id}/branches", summary="List branches")
132 async def list_branches_endpoint(
133 repo_id: str,
134 _claims: TokenClaims | None = Depends(optional_token),
135 session: AsyncSession = Depends(get_session),
136 ) -> Response:
137 rows = (
138 await session.execute(
139 select(db.MusehubBranch).where(db.MusehubBranch.repo_id == repo_id)
140 )
141 ).scalars().all()
142 branches = [
143 {"name": b.name, "head_commit_id": b.head_commit_id}
144 for b in rows
145 ]
146 import json
147 return Response(
148 content=json.dumps({"branches": branches}),
149 media_type="application/json",
150 )
151
152
153 @router.get("/api/repos/{repo_id}/commits", summary="List commits (paginated)")
154 async def list_commits_endpoint(
155 repo_id: str,
156 branch: str = Query("main"),
157 page: int = Query(1, ge=1),
158 per_page: int = Query(30, ge=1, le=100),
159 q: str | None = Query(None, description="Semantic search query"),
160 _claims: TokenClaims | None = Depends(optional_token),
161 session: AsyncSession = Depends(get_session),
162 ) -> Response:
163 """List commits for a repo with optional semantic search."""
164 import json as _json
165 if q:
166 qdrant = qdrant_svc.get_qdrant()
167 if qdrant is not None:
168 results = await qdrant_svc.semantic_search_commits(qdrant, q, repo_id=repo_id, limit=per_page)
169 return Response(
170 content=_json.dumps({"commits": results, "semantic": True}),
171 media_type="application/json",
172 )
173
174 offset = (page - 1) * per_page
175 commits, total = await list_commits(session, repo_id, branch=branch, limit=per_page, offset=offset)
176 return Response(
177 content=_json.dumps({
178 "commits": [c.model_dump() for c in commits],
179 "total": total,
180 "page": page,
181 "per_page": per_page,
182 }),
183 media_type="application/json",
184 )
185
186
187 @router.get("/api/repos/{repo_id}/commits/{commit_id}", summary="Get a single commit")
188 async def get_commit_endpoint(
189 repo_id: str,
190 commit_id: str,
191 _claims: TokenClaims | None = Depends(optional_token),
192 session: AsyncSession = Depends(get_session),
193 ) -> Response:
194 row = await session.get(db.MusehubCommit, commit_id)
195 if row is None or row.repo_id != repo_id:
196 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="commit not found")
197
198 from musehub.services.musehub_wire import _to_wire_commit
199 wire = _to_wire_commit(row)
200 return Response(content=wire.model_dump_json(), media_type="application/json")