gabriel / musehub public
ui_commits.py python
566 lines 23.5 KB
Raw
sha256:e17dff4303a5885a1a61af6b39594fe316aeb62821bad17ccb66c52128a315f5 fix: enforce repo visibility gate on all SSR route handlers… Sonnet 4.6 minor ⚠ breaking 90 days ago
1 """MuseHub commit-scoped UI route handlers.
2
3 Contains the paginated commits list page, the commit detail page, and the
4 commit diff page. All three handlers are repo-scoped and live under the
5 ``/{owner}/{repo_slug}/commits`` prefix.
6
7 Shared helpers are imported from ``_ui_helpers.py`` and ``_templates.py``.
8 The ``negotiate_response`` content-negotiation helper comes from
9 ``negotiate.py``.
10 """
11
12 import asyncio
13 import re
14
15 from fastapi import APIRouter, Depends, HTTPException, Query, Request
16 from fastapi import status as http_status
17 from fastapi.responses import Response
18 from sqlalchemy import func, select as sa_select
19 from sqlalchemy.ext.asyncio import AsyncSession
20 from starlette.responses import Response as StarletteResponse
21
22 from musehub.api.routes.musehub._templates import templates
23 from musehub.api.validation import BranchParam, FilePathParam, SlugParam
24 from musehub.auth.dependencies import TokenClaims, optional_token
25 from musehub.api.routes.musehub._ui_helpers import (
26 _breadcrumbs,
27 _og_tags,
28 _resolve_repo,
29 )
30 from musehub.types.json_types import JSONObject, StrDict
31 from musehub.api.routes.musehub.htmx_helpers import htmx_fragment_or_full
32 from musehub.api.routes.musehub.json_alternate import json_or_html
33 from musehub.api.routes.musehub.negotiate import negotiate_response
34 from musehub.db import get_db
35 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
36 from musehub.db.utils import escape_like
37 from musehub.models.musehub import CommitListResponse, CommitResponse
38 from musehub.services import musehub_repository
39
40 router = APIRouter(prefix="", tags=["musehub-ui"])
41
42 @router.get(
43 "/{owner}/{repo_slug}/commits",
44 summary="MuseHub commits list page",
45 )
46 async def commits_list_page(
47 request: Request,
48 owner: SlugParam,
49 repo_slug: SlugParam,
50 branch: str | None = Query(None, description="Filter commits by branch name"),
51 cursor: str | None = Query(None, description="Pagination cursor from previous nextCursor"),
52 limit: int = Query(30, ge=1, le=200, description="Commits per page"),
53 format: str | None = Query(None, description="Force response format: 'json' or omit for HTML"),
54 author: str | None = Query(None, description="Filter by commit author"),
55 q: str | None = Query(None, description="Full-text search over commit messages"),
56 date_from: str | None = Query(None, alias="dateFrom", description="ISO date lower bound (inclusive), e.g. 2026-01-01"),
57 date_to: str | None = Query(None, alias="dateTo", description="ISO date upper bound (inclusive), e.g. 2026-12-31"),
58 tag_filter: str | None = Query(None, alias="tag", description="Filter by muse_tag prefix, e.g. 'emotion:happy', 'stage:chorus'"),
59 db: AsyncSession = Depends(get_db),
60 claims: TokenClaims | None = Depends(optional_token),
61 ) -> StarletteResponse:
62 """Render the cursor-paginated commits list page or return structured commit data as JSON.
63
64 HTML (default): renders ``commits.html`` with:
65 - Rich filter bar: author dropdown, date range pickers, message search, tag filter.
66 - Per-commit metadata badges: tempo (♩ BPM), key, emotion, stage, instruments.
67 - Compare mode: checkbox per row; selecting exactly 2 activates a compare link.
68 - Visual mini-lane: DAG dots with merge-commit indicators.
69 - Cursor-paginated history, branch selector.
70
71 JSON (``Accept: application/json`` or ``?format=json``): returns
72 ``CommitListResponse`` with the newest commits first and a ``nextCursor`` for pagination.
73
74 Filter params (``author``, ``q``, ``dateFrom``, ``dateTo``, ``tag``) are
75 applied server-side so pagination counts stay accurate. They are forwarded
76 through pagination links so the filter state persists across pages.
77 """
78 from datetime import date as _date, datetime as _datetime, timedelta as _td, timezone as _tz
79
80 import sqlalchemy as _sa
81
82 repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
83
84 # ── Build the filtered SQLAlchemy query ──────────────────────────────────
85 base_stmt = (
86 sa_select(MusehubCommit)
87 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
88 .where(MusehubCommitRef.repo_id == repo_id)
89 )
90 if branch:
91 base_stmt = base_stmt.where(MusehubCommit.branch == branch)
92 if author:
93 base_stmt = base_stmt.where(MusehubCommit.author == author)
94 if q:
95 base_stmt = base_stmt.where(
96 MusehubCommit.message.ilike(f"%{escape_like(q)}%", escape="\\")
97 )
98 if date_from:
99 try:
100 df = _date.fromisoformat(date_from)
101 df_dt = _datetime(df.year, df.month, df.day, tzinfo=_tz.utc)
102 base_stmt = base_stmt.where(MusehubCommit.timestamp >= df_dt)
103 except ValueError:
104 pass # ignore malformed date — show all results
105 if date_to:
106 try:
107 dt = _date.fromisoformat(date_to)
108 dt_end = _datetime(dt.year, dt.month, dt.day, tzinfo=_tz.utc) + _td(days=1)
109 base_stmt = base_stmt.where(MusehubCommit.timestamp < dt_end)
110 except ValueError:
111 pass
112
113 # tag_filter matches muse_tag namespace prefixes embedded in commit messages
114 # (e.g. "emotion:happy", "stage:chorus") since musehub_commits has no
115 # separate tags column — tags live in commit messages by convention.
116 if tag_filter:
117 base_stmt = base_stmt.where(
118 MusehubCommit.message.ilike(f"%{escape_like(tag_filter)}%", escape="\\")
119 )
120
121 total_stmt = sa_select(func.count()).select_from(base_stmt.subquery())
122 total: int = (await db.execute(total_stmt)).scalar_one()
123
124 rows_stmt = base_stmt.order_by(_sa.desc(MusehubCommit.timestamp))
125
126 # Apply cursor: filter rows where timestamp < cursor_dt (DESC ordering)
127 # Normalize space→+ because URL-decoding can corrupt the ISO timezone offset (+00:00).
128 if cursor:
129 try:
130 cursor_dt = _datetime.fromisoformat(cursor.replace(" ", "+"))
131 rows_stmt = rows_stmt.where(MusehubCommit.timestamp < cursor_dt)
132 except ValueError:
133 pass # ignore malformed cursor, start from beginning
134
135 rows_stmt = rows_stmt.limit(limit + 1)
136 rows = (await db.execute(rows_stmt)).scalars().all()
137 has_more = len(rows) > limit
138 page_rows = rows[:limit]
139 next_cursor = page_rows[-1].timestamp.isoformat() if (page_rows and has_more) else None
140
141 # Build CommitResponse objects inline — same mapping as the service layer.
142 commits = [
143 CommitResponse(
144 commit_id=r.commit_id,
145 branch=r.branch,
146 parent_ids=list(r.parent_ids or []),
147 message=r.message,
148 author=r.author,
149 timestamp=r.timestamp,
150 snapshot_id=r.snapshot_id,
151 )
152 for r in page_rows
153 ]
154 commits_enriched: list[JSONObject] = [
155 c.model_dump(mode="json")
156 for c in commits
157 ]
158
159 # ── Distinct authors for the filter dropdown ──────────────────────────────
160 author_stmt = (
161 sa_select(MusehubCommit.author)
162 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
163 .where(MusehubCommitRef.repo_id == repo_id)
164 .distinct()
165 .order_by(MusehubCommit.author)
166 )
167 all_authors: list[str] = list((await db.execute(author_stmt)).scalars().all())
168
169 branches = await musehub_repository.list_branches(db, repo_id)
170
171 # ── Active filter set (forwarded to pagination links) ────────────────────
172 active_filters: StrDict = {}
173 if cursor:
174 active_filters["cursor"] = cursor
175 if branch:
176 active_filters["branch"] = branch
177 if author:
178 active_filters["author"] = author
179 if q:
180 active_filters["q"] = q
181 if date_from:
182 active_filters["dateFrom"] = date_from
183 if date_to:
184 active_filters["dateTo"] = date_to
185 if tag_filter:
186 active_filters["tag"] = tag_filter
187
188 ctx = {
189 "owner": owner,
190 "repo_slug": repo_slug,
191 "repo_id": repo_id,
192 "base_url": base_url,
193 "current_page": "commits",
194 "commits": commits_enriched,
195 "total": total,
196 "next_cursor": next_cursor,
197 "limit": limit,
198 "branch": branch,
199 "branches": branches,
200 "all_authors": all_authors,
201 "filter_author": author or "",
202 "filter_q": q or "",
203 "filter_date_from": date_from or "",
204 "filter_date_to": date_to or "",
205 "filter_tag": tag_filter or "",
206 "active_filters": active_filters,
207 "breadcrumb_data": _breadcrumbs(
208 (owner, f"/{owner}"),
209 (repo_slug, base_url),
210 ("commits", ""),
211 ),
212 }
213 ctx.update(nav_ctx)
214 return await negotiate_response(
215 request=request,
216 template_name="musehub/pages/commits.html",
217 context=ctx,
218 templates=templates,
219 json_data=CommitListResponse(commits=commits, total=total, next_cursor=next_cursor),
220 format_param=format,
221 fragment_template="musehub/fragments/commit_rows.html",
222 )
223
224 @router.get(
225 "/{owner}/{repo_slug}/commits/{commit_id}",
226 summary="MuseHub commit detail page",
227 )
228 async def commit_page(
229 request: Request,
230 owner: SlugParam,
231 repo_slug: SlugParam,
232 commit_id: str,
233 db: AsyncSession = Depends(get_db),
234 claims: TokenClaims | None = Depends(optional_token),
235 ) -> Response:
236 """Render the commit detail page — SSR metadata + comments.
237
238 Partial SSR strategy (HTMX):
239 - Commit header (message, author, timestamp, SHA, parent SHAs) → server-rendered.
240 - Comment thread → server-rendered; HTMX refreshes it after new comment POST.
241
242 HTMX requests (``HX-Request: true``) receive only the comment fragment so the
243 comment form can re-render the thread without a full page reload.
244
245 Returns 404 when ``commit_id`` is not found in this repo.
246 """
247 repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
248 commit = await musehub_repository.get_commit(db, repo_id, commit_id)
249 if commit is None:
250 raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="Commit not found")
251
252 short_commit_id = commit_id
253 api_base = f"/api/repos/{repo_id}"
254
255 # ── Parallel: comments + sibling commits on same branch ──────────────
256 async def _q_comments() -> list[JSONObject]:
257 return []
258
259 async def _q_branch_commits() -> list[CommitResponse]:
260 result = await musehub_repository.list_commits(
261 db, repo_id, branch=commit.branch, limit=100
262 )
263 return result.commits
264
265 comments, branch_commits = await asyncio.gather(_q_comments(), _q_branch_commits())
266
267 # ── Prev / next siblings on branch ───────────────────────────────────
268 cur_pos = next((i for i, c in enumerate(branch_commits) if c.commit_id == commit_id), None)
269 older_commit: CommitResponse | None = branch_commits[cur_pos + 1] if (cur_pos is not None and cur_pos + 1 < len(branch_commits)) else None
270 newer_commit: CommitResponse | None = branch_commits[cur_pos - 1] if (cur_pos is not None and cur_pos > 0) else None
271
272 # ── Commit metadata from ORM ──────────────────────────────────────────
273 orm_row = await db.get(MusehubCommit, commit_id)
274
275 # Parse conventional commit type from message
276 _CONV_RE = re.compile(r"^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([^)]+\))?(!)?:")
277 _cm = _CONV_RE.match(commit.message.strip())
278 commit_type = _cm.group(1) if _cm else ""
279 commit_scope = (_cm.group(2) or "").strip("()") if _cm else ""
280 is_breaking = bool((_cm and _cm.group(3)) or (orm_row.breaking_changes if orm_row else []))
281
282 structured_delta: JSONObject = orm_row.structured_delta if (orm_row and isinstance(orm_row.structured_delta, dict)) else {}
283
284 # Snapshot diff: compare this snapshot vs parent snapshot
285 parent_snapshot_id: str | None = None
286 if commit.parent_ids:
287 parent_orm = await db.get(MusehubCommit, commit.parent_ids[0])
288 parent_snapshot_id = parent_orm.snapshot_id if parent_orm else None
289
290 snapshot_diff = await musehub_repository.get_snapshot_diff(
291 db, repo_id, commit.snapshot_id, parent_snapshot_id
292 )
293
294 # ── Provenance dict (sidebar) ─────────────────────────────────────────
295 provenance = {
296 "sem_ver_bump": (orm_row.sem_ver_bump or "none") if orm_row else "none",
297 "agent_id": (orm_row.agent_id or "") if orm_row else "",
298 "model_id": (orm_row.model_id or "") if orm_row else "",
299 "toolchain_id": (orm_row.toolchain_id or "") if orm_row else "",
300 "prompt_hash": (orm_row.prompt_hash or "") if orm_row else "",
301 "signature": (orm_row.signature or "") if orm_row else "",
302 "signer_key_id": (orm_row.signer_key_id or "") if orm_row else "",
303 "format_version": 1,
304 "reviewed_by": list(orm_row.reviewed_by or []) if orm_row else [],
305 "test_runs": int(orm_row.test_runs or 0) if orm_row else 0,
306 "breaking_changes": list(orm_row.breaking_changes or []) if orm_row else [],
307 "is_agent": bool(orm_row.agent_id if orm_row else ""),
308 }
309
310 # Split commit message into subject + body
311 msg_parts = commit.message.split("\n", 1)
312 commit_subject = msg_parts[0].strip()
313 commit_body = msg_parts[1].strip() if len(msg_parts) > 1 else ""
314
315 # ── Dimension strip: aggregate counts from structured_delta + snapshot ──
316 def _count_sym_ops(delta: JSONObject, op_filter: str | None = None) -> int:
317 total = 0
318 for fop in delta.get("ops", []):
319 if not isinstance(fop, dict):
320 continue
321 for cop in (fop.get("child_ops") or []):
322 if not isinstance(cop, dict):
323 continue
324 if op_filter is None or cop.get("op") == op_filter:
325 total += 1
326 return total
327
328 sym_added = _count_sym_ops(structured_delta, "insert")
329 sym_removed = _count_sym_ops(structured_delta, "delete")
330 sym_modified = _count_sym_ops(structured_delta, "replace") + _count_sym_ops(structured_delta, "patch")
331 sym_total = _count_sym_ops(structured_delta)
332 files_added_n = len(snapshot_diff.get("added", []))
333 files_modified_n = len(snapshot_diff.get("modified", []))
334 files_removed_n = len(snapshot_diff.get("removed", []))
335 files_changed_n = files_added_n + files_modified_n + files_removed_n
336
337 # Dead code: child ops with op=delete and "0 callers" in summary, or flagged
338 dead_code_removed = sum(
339 1 for fop in structured_delta.get("ops", [])
340 if isinstance(fop, dict)
341 for cop in (fop.get("child_ops") or [])
342 if isinstance(cop, dict) and cop.get("op") == "delete" and (
343 "0 callers" in (cop.get("content_summary") or "")
344 or "dead" in (cop.get("content_summary") or "").lower()
345 )
346 )
347
348 dims = {
349 "sym_added": sym_added,
350 "sym_removed": sym_removed,
351 "sym_modified": sym_modified,
352 "sym_total": sym_total,
353 "files_added": files_added_n,
354 "files_modified": files_modified_n,
355 "files_removed": files_removed_n,
356 "files_changed": files_changed_n,
357 "dead_code_removed": dead_code_removed,
358 "test_runs": provenance["test_runs"],
359 "total_files": snapshot_diff.get("total_files", 0),
360 "domain": structured_delta.get("domain") or "code",
361 }
362
363 ctx = {
364 "owner": owner,
365 "repo_slug": repo_slug,
366 "repo_id": repo_id,
367 "commit_id": commit_id,
368 "short_commit_id": short_commit_id,
369 "base_url": base_url,
370 "current_page": "commits",
371 "commit": commit.model_dump(mode="json"),
372 "commit_subject": commit_subject,
373 "commit_body": commit_body,
374 "commit_type": commit_type,
375 "commit_scope": commit_scope,
376 "is_breaking": is_breaking,
377 "comments": comments,
378 "provenance": provenance,
379 "structured_delta": structured_delta,
380 "snapshot_diff": snapshot_diff,
381 "dims": dims,
382 "older_commit": older_commit.model_dump(mode="json") if older_commit else None,
383 "newer_commit": newer_commit.model_dump(mode="json") if newer_commit else None,
384 "branch_commit_count": len(branch_commits),
385 "branch_position": (cur_pos + 1) if cur_pos is not None else None,
386 "breadcrumb_data": _breadcrumbs(
387 (owner, f"/{owner}"),
388 (repo_slug, base_url),
389 ("commits", f"{base_url}/commits"),
390 (short_commit_id, ""),
391 ),
392 "og_meta": _og_tags(
393 title=f"Commit {short_commit_id} · {owner}/{repo_slug} — MuseHub",
394 description=commit_subject,
395 og_type="article",
396 ),
397 }
398 ctx.update(nav_ctx)
399 return await htmx_fragment_or_full(
400 request,
401 templates,
402 ctx,
403 full_template="musehub/pages/commit_detail.html",
404 fragment_template="musehub/fragments/commit_comments.html",
405 )
406
407 # ── Diff SSR helpers ──────────────────────────────────────────────────────────
408
409 _MAX_DIFF_FILES = 50 # cap file count to keep page fast
410 _MAX_DIFF_LINES = 300 # cap annotated lines per file
411
412 async def _build_file_diffs(
413 owner: str,
414 repo_slug: str,
415 snapshot_diff: JSONObject,
416 new_manifest: dict[str, str],
417 old_manifest: dict[str, str],
418 ) -> list[JSONObject]:
419 """Compute Cohen-annotated unified diffs for every changed file.
420
421 Returns a list of dicts, one per file:
422 path, file_type, lines (annotated), added_count, removed_count, truncated
423 """
424 import difflib
425 from muse.core.cohen_transform import annotate_hunk_action
426 from musehub.storage.backends import get_backend as _get_storage_backend
427
428 backend = _get_storage_backend()
429
430 added: list[str] = snapshot_diff.get("added", [])
431 modified: list[str] = snapshot_diff.get("modified", [])
432 removed: list[str] = snapshot_diff.get("removed", [])
433
434 # Collect all object IDs we need in one batch fetch
435 oids_needed: list[str] = []
436 for path in added[:_MAX_DIFF_FILES]:
437 if oid := new_manifest.get(path):
438 oids_needed.append(oid)
439 for path in removed[:_MAX_DIFF_FILES]:
440 if oid := old_manifest.get(path):
441 oids_needed.append(oid)
442 for path in modified[:_MAX_DIFF_FILES]:
443 if oid := old_manifest.get(path):
444 oids_needed.append(oid)
445 if oid := new_manifest.get(path):
446 oids_needed.append(oid)
447 oids_needed = list(dict.fromkeys(oids_needed))
448
449 obj_map: dict[str, bytes] = {}
450 if oids_needed:
451 try:
452 fetched = await backend.get_batch(oids_needed)
453 obj_map = {k: v for k, v in fetched.items() if v is not None}
454 except Exception:
455 pass
456
457 def _lines(oid: str | None) -> list[str]:
458 if not oid:
459 return []
460 raw = obj_map.get(oid)
461 if not raw:
462 return []
463 from musehub.types.compression import decompress_if_needed
464 return decompress_if_needed(raw).decode("utf-8", errors="replace").splitlines(keepends=True)
465
466 file_diffs: list[JSONObject] = []
467
468 def _process(path: str, file_type: str, old_lines: list[str], new_lines: list[str]) -> None:
469 raw = list(difflib.unified_diff(old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="", n=3))
470 annotated = annotate_hunk_action(raw, "change") if raw else []
471 # Strip --- / +++ header lines (filenames are shown in the card header)
472 body = [ln for ln in annotated if not ln.startswith("---") and not ln.startswith("+++")]
473 added_count = sum(1 for ln in body if ln.startswith("+"))
474 removed_count = sum(1 for ln in body if ln.startswith("-"))
475 truncated = len(body) > _MAX_DIFF_LINES
476 file_diffs.append({
477 "path": path,
478 "file_type": file_type,
479 "lines": body[:_MAX_DIFF_LINES],
480 "added_count": added_count,
481 "removed_count": removed_count,
482 "truncated": truncated,
483 })
484
485 for path in added[:_MAX_DIFF_FILES]:
486 _process(path, "added", [], _lines(new_manifest.get(path)))
487 for path in removed[:_MAX_DIFF_FILES]:
488 _process(path, "removed", _lines(old_manifest.get(path)), [])
489 for path in modified[:_MAX_DIFF_FILES]:
490 _process(path, "modified", _lines(old_manifest.get(path)), _lines(new_manifest.get(path)))
491
492 return file_diffs
493
494
495 @router.get(
496 "/{owner}/{repo_slug}/commits/{commit_id}/diff",
497 summary="MuseHub diff view",
498 )
499 async def diff_page(
500 request: Request,
501 owner: SlugParam,
502 repo_slug: SlugParam,
503 commit_id: str,
504 db: AsyncSession = Depends(get_db),
505 claims: TokenClaims | None = Depends(optional_token),
506 ) -> Response:
507 """Render the diff for a commit — fully server-side rendered."""
508 from musehub.services.musehub_snapshot import get_snapshot_manifest
509
510 repo_id, base_url, nav_ctx = await _resolve_repo(owner, repo_slug, db, claims)
511 orm_repo_d = await db.get(MusehubRepo, repo_id)
512 from musehub.api.routes.musehub.ui_view import _get_domain_for_repo as _gdfr_d
513 domain_ctx_d = await _gdfr_d(db, repo_id, getattr(orm_repo_d, "domain_id", None))
514
515 commit = await musehub_repository.get_commit(db, repo_id, commit_id)
516 commit_data = commit.model_dump(by_alias=True, mode="json") if commit else None
517
518 structured_delta_d: JSONObject = {}
519 snapshot_diff_d: JSONObject = {"added": [], "modified": [], "removed": [], "total_files": 0}
520 parent_id_d: str | None = None
521 file_diffs: list[JSONObject] = []
522
523 if commit:
524 orm_row_d = await db.get(MusehubCommit, commit_id)
525 structured_delta_d = orm_row_d.structured_delta if (orm_row_d and isinstance(orm_row_d.structured_delta, dict)) else {}
526 parent_id_d = commit.parent_ids[0] if commit.parent_ids else None
527
528 parent_snapshot_id_d: str | None = None
529 if parent_id_d:
530 parent_orm_d = await db.get(MusehubCommit, parent_id_d)
531 parent_snapshot_id_d = parent_orm_d.snapshot_id if parent_orm_d else None
532
533 snapshot_diff_d = await musehub_repository.get_snapshot_diff(
534 db, repo_id, commit.snapshot_id, parent_snapshot_id_d
535 )
536
537 # Build manifests and compute SSR diffs
538 try:
539 new_manifest: dict[str, str] = await get_snapshot_manifest(db, commit.snapshot_id) if commit.snapshot_id else {}
540 old_manifest: dict[str, str] = await get_snapshot_manifest(db, parent_snapshot_id_d) if parent_snapshot_id_d else {}
541 file_diffs = await _build_file_diffs(owner, repo_slug, snapshot_diff_d, new_manifest, old_manifest)
542 except Exception:
543 file_diffs = []
544
545 ctx = {
546 "owner": owner,
547 "repo_slug": repo_slug,
548 "repo_id": repo_id,
549 "commit_id": commit_id,
550 "short_id": commit_id,
551 "short_commit_id": commit_id,
552 "base_url": base_url,
553 "current_page": "commits",
554 "domain": domain_ctx_d,
555 "commit": commit_data,
556 "structured_delta": structured_delta_d,
557 "snapshot_diff": snapshot_diff_d,
558 "parent_id": parent_id_d,
559 "file_diffs": file_diffs,
560 }
561 ctx.update(nav_ctx)
562 return json_or_html(
563 request,
564 lambda: templates.TemplateResponse(request, "musehub/pages/diff.html", ctx),
565 ctx,
566 )
File History 1 commit
sha256:e17dff4303a5885a1a61af6b39594fe316aeb62821bad17ccb66c52128a315f5 fix: enforce repo visibility gate on all SSR route handlers… Sonnet 4.6 minor 90 days ago