gabriel / musehub public
ui_releases.py python
285 lines 10.5 KB
Raw
sha256:e17dff4303a5885a1a61af6b39594fe316aeb62821bad17ccb66c52128a315f5 fix: enforce repo visibility gate on all SSR route handlers… Sonnet 4.6 minor ⚠ breaking 82 days ago
1 """MuseHub releases, branches, and tags UI route handlers.
2
3 Endpoints:
4 GET /{owner}/{repo_slug}/releases — release list page
5 GET /{owner}/{repo_slug}/releases/{tag} — release detail page
6 GET /{owner}/{repo_slug}/branches — branches list page
7 GET /{owner}/{repo_slug}/tags — tags list page
8 """
9
10 import asyncio
11 import logging
12
13 from fastapi import APIRouter, Depends, HTTPException, Query, Request
14 from fastapi import status as http_status
15 from sqlalchemy import func
16 from sqlalchemy import select as sa_select
17 from sqlalchemy.ext.asyncio import AsyncSession
18 from starlette.responses import Response
19
20 from musehub.api.routes.musehub._templates import templates
21 from musehub.api.validation import BranchParam, FilePathParam, SlugParam
22 from musehub.auth.dependencies import TokenClaims, optional_token
23 from musehub.api.routes.musehub._ui_helpers import _resolve_repo, _resolve_repo_full
24 from musehub.api.routes.musehub.htmx_helpers import htmx_fragment_or_full
25 from musehub.api.routes.musehub.ui_jsonld import jsonld_release, render_jsonld_script
26 from musehub.db import get_db
27 from musehub.db.musehub_repo_models import MusehubCommit
28 from musehub.db.musehub_social_models import MusehubIssue, MusehubProposal
29 from musehub.services import musehub_releases, musehub_repository
30 from musehub.types.json_types import JSONObject, StrDict
31
32 logger = logging.getLogger(__name__)
33
34 router = APIRouter(prefix="", tags=["musehub-ui"])
35
36 @router.get(
37 "/{owner}/{repo_slug}/releases",
38 summary="MuseHub release list page",
39 )
40 async def release_list_page(
41 request: Request,
42 owner: SlugParam,
43 repo_slug: SlugParam,
44 cursor: str | None = Query(None, description="Cursor from previous page nextCursor"),
45 limit: int = Query(20, ge=1, le=100, description="Releases per page"),
46 db: AsyncSession = Depends(get_db),
47 claims: TokenClaims | None = Depends(optional_token),
48 ) -> Response:
49 """Render the supercharged release list page (SSR).
50
51 The latest stable release is highlighted as a hero card in the page shell.
52 The HTMX-swappable fragment renders the cursor-paginated list of remaining
53 releases below the hero. Cursor is the ``tag`` of the last release shown;
54 pass it as ``?cursor=`` on the next request. ``next_cursor`` is ``null``
55 on the last page.
56 """
57 repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
58 releases_result = await musehub_releases.list_releases(db, repo_id)
59 all_releases = releases_result.releases
60
61 # Split: hero = latest (first in newest-first list), list = everything else.
62 latest_release = all_releases[0] if all_releases else None
63 other_releases = all_releases[1:] if len(all_releases) > 1 else []
64
65 # Apply cursor filter (in-memory; cursor = tag of last release shown).
66 if cursor:
67 try:
68 cursor_idx = next(
69 i for i, r in enumerate(other_releases) if r.tag == cursor
70 )
71 start_idx = cursor_idx + 1
72 except StopIteration:
73 start_idx = 0
74 else:
75 start_idx = 0
76 releases = other_releases[start_idx : start_idx + limit]
77 next_cursor: str | None = (
78 releases[-1].tag if releases and start_idx + limit < len(other_releases) else None
79 )
80
81 stable_count = sum(
82 1 for r in all_releases if r.channel == "stable" and not r.is_draft
83 )
84 prerelease_count = sum(1 for r in all_releases if r.channel != "stable")
85 draft_count = sum(1 for r in all_releases if r.is_draft)
86
87 ctx: JSONObject = {
88 "owner": owner,
89 "repo_slug": repo_slug,
90 "repo_id": repo_id,
91 "base_url": base_url,
92 "current_page": "releases",
93 "releases": releases,
94 "total": len(all_releases),
95 "other_count": len(other_releases),
96 "next_cursor": next_cursor,
97 "latest_release": latest_release,
98 "stable_count": stable_count,
99 "prerelease_count": prerelease_count,
100 "draft_count": draft_count,
101 }
102 ctx.update(nav_ctx)
103 return await htmx_fragment_or_full(
104 request,
105 templates,
106 ctx,
107 full_template="musehub/pages/releases.html",
108 fragment_template="musehub/fragments/release_rows.html",
109 )
110
111 @router.get(
112 "/{owner}/{repo_slug}/branches",
113 summary="MuseHub branches page",
114 )
115 async def branches_page(
116 request: Request,
117 owner: SlugParam,
118 repo_slug: SlugParam,
119 db: AsyncSession = Depends(get_db),
120 claims: TokenClaims | None = Depends(optional_token),
121 ) -> Response:
122 """Render the branches list page (SSR)."""
123 repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
124 detail = await musehub_repository.list_branches_with_detail(db, repo_id)
125
126 # Fetch head commit metadata (message + timestamp) for all branches in one query.
127 head_ids = [b.head_commit_id for b in detail.branches if b.head_commit_id]
128 commit_meta: dict[str, MusehubCommit] = {}
129 if head_ids:
130 stmt = sa_select(MusehubCommit).where(
131 MusehubCommit.commit_id.in_(head_ids),
132 )
133 rows = (await db.execute(stmt)).scalars().all()
134 commit_meta = {r.commit_id: r for r in rows}
135
136 branches_ctx = []
137 for b in detail.branches:
138 commit = commit_meta.get(b.head_commit_id or "")
139 branches_ctx.append({
140 "name": b.name,
141 "head_commit_id": b.head_commit_id or "",
142 "head_short": (b.head_commit_id or "")[:8],
143 "is_default": b.is_default,
144 "ahead": b.ahead_count,
145 "behind": b.behind_count,
146 "message": (commit.message or "")[:80] if commit else "",
147 "timestamp": commit.timestamp.isoformat() if commit and commit.timestamp else None,
148 "author": commit.author or "" if commit else "",
149 })
150
151 default_name = detail.default_branch
152 ctx = {
153 "owner": owner,
154 "repo_slug": repo_slug,
155 "repo_id": repo_id,
156 "base_url": base_url,
157 "current_page": "branches",
158 "branches": branches_ctx,
159 "default_name": default_name,
160 "branch_count": len(branches_ctx),
161 }
162 ctx.update(nav_ctx)
163 return await htmx_fragment_or_full(
164 request,
165 templates,
166 ctx,
167 full_template="musehub/pages/branches.html",
168 fragment_template="musehub/fragments/branch_list_rows.html",
169 )
170
171 @router.get(
172 "/{owner}/{repo_slug}/tags",
173 summary="MuseHub tags page",
174 )
175 async def tags_page(
176 request: Request,
177 owner: SlugParam,
178 repo_slug: SlugParam,
179 namespace: str | None = Query(None),
180 db: AsyncSession = Depends(get_db),
181 claims: TokenClaims | None = Depends(optional_token),
182 ) -> Response:
183 """Render the tags (releases) list page (SSR)."""
184 repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
185 all_releases_resp = await musehub_releases.list_releases(db, repo_id, include_drafts=True, limit=10000)
186 all_releases = all_releases_resp.releases
187 if namespace:
188 releases = [r for r in all_releases if r.tag.startswith(f"{namespace}:")]
189 else:
190 releases = all_releases
191 ctx: JSONObject = {
192 "owner": owner,
193 "repo_slug": repo_slug,
194 "repo_id": repo_id,
195 "base_url": base_url,
196 "current_page": "tags",
197 "releases": releases,
198 "namespace": namespace,
199 }
200 ctx.update(nav_ctx)
201 return await htmx_fragment_or_full(
202 request,
203 templates,
204 ctx,
205 full_template="musehub/pages/tags.html",
206 fragment_template="musehub/fragments/tag_rows.html",
207 )
208
209 @router.get(
210 "/{owner}/{repo_slug}/releases/{tag}",
211 summary="MuseHub release detail page",
212 )
213 async def release_detail_page(
214 request: Request,
215 owner: SlugParam,
216 repo_slug: SlugParam,
217 tag: str,
218 db: AsyncSession = Depends(get_db),
219 claims: TokenClaims | None = Depends(optional_token),
220 ) -> Response:
221 """Render the release detail page: title, release notes, download packages.
222
223 Release metadata, changelog (body), download package cards, and asset list
224 are all resolved server-side and injected into the Jinja2 template. The
225 audio player div is rendered as an SSR container that WaveSurfer JS (issue
226 #583) initialises client-side once the page loads.
227
228 Returns 404 when the tag does not exist in the repository.
229 """
230 repo, base_url = await _resolve_repo_full(owner, repo_slug, db, claims)
231 repo_id = str(repo.repo_id)
232 proposal_ct, issue_ct = await asyncio.gather(
233 db.execute(
234 sa_select(func.count()).select_from(MusehubProposal).where(
235 MusehubProposal.repo_id == repo_id,
236 MusehubProposal.state.in_(("open", "in_review", "approved", "drafting", "settling")),
237 )
238 ),
239 db.execute(
240 sa_select(func.count()).select_from(MusehubIssue).where(
241 MusehubIssue.repo_id == repo_id,
242 MusehubIssue.state == "open",
243 )
244 ),
245 )
246 nav_ctx_release: JSONObject = {
247 "repo_tags": repo.tags or [],
248 "repo_visibility": repo.visibility or "private",
249 "nav_open_proposal_count": proposal_ct.scalar_one_or_none() or 0,
250 "nav_open_issue_count": issue_ct.scalar_one_or_none() or 0,
251 }
252 release = await musehub_releases.get_release_by_tag(db, repo_id, tag)
253 if release is None:
254 raise HTTPException(
255 status_code=http_status.HTTP_404_NOT_FOUND,
256 detail=f"Release '{tag}' not found in '{owner}/{repo_slug}'",
257 )
258 assets_resp = await musehub_releases.list_release_assets(db, release.release_id, tag)
259 page_url = str(request.url)
260 jsonld_script = render_jsonld_script(jsonld_release(release, repo, page_url))
261
262 # Serialize to plain dicts so templates can use | tojson safely
263 release_dict = release.model_dump(mode="json")
264 assets_data = [a.model_dump(mode="json") for a in assets_resp.assets]
265 total_downloads = sum(a.download_count or 0 for a in assets_resp.assets)
266
267 # Count which download package formats are present
268 dl_urls: StrDict = release_dict.get("download_urls") or {}
269 available_formats = [k for k, v in dl_urls.items() if v]
270
271 ctx: JSONObject = {
272 "owner": owner,
273 "repo_slug": repo_slug,
274 "repo_id": repo_id,
275 "tag": tag,
276 "base_url": base_url,
277 "current_page": "releases",
278 "release": release_dict,
279 "assets": assets_data,
280 "total_downloads": total_downloads,
281 "available_formats": available_formats,
282 "jsonld_script": jsonld_script,
283 }
284 ctx.update(nav_ctx_release)
285 return templates.TemplateResponse(request, "musehub/pages/release_detail.html", ctx)
File History 1 commit
sha256:e17dff4303a5885a1a61af6b39594fe316aeb62821bad17ccb66c52128a315f5 fix: enforce repo visibility gate on all SSR route handlers… Sonnet 4.6 minor 82 days ago